Engineering Atlas › Low-Level Design

Classes and objects in practice

14 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

Data plus the rules that guard it

A music app stores playlists as dictionaries: {"name": "Run", "tracks": [...], "limit": 3600}. Three months later there are playlists with empty names, tracks with negative durations from a bad import, and a playlist 40 minutes over its limit because one code path appended to tracks directly.

A class moves the rules next to the data. The constructor refuses to build an invalid playlist, and the only way to change the track list is add, which checks the limit first. Every other function can then assume a playlist is valid. That is the real product of a class: the places a bug can create bad data shrink from "everywhere" to "these six methods".

Constructors establish, methods preserve

An invariant has two halves. The constructor establishes it: trim the name, reject empty ones, reject a limit below one second. Each mutating method preserves it: check first, then mutate, so a failed call leaves the object unchanged. The order matters. A method that appends a track and then raises "over limit" has already broken the playlist; the exception just reports the damage.

Two leaks undo this work. Returning the internal list (return self._tracks) lets callers append behind the method's back, so return a copy or an immutable view. Accepting a list in the constructor and storing it directly lets the caller keep a reference and mutate it later, so copy on the way in as well.

Values and entities

Two Track("Intro", "Nils", 214) objects are interchangeable: same title, artist and duration, same thing. A track is a value, and values should be immutable, compare by their fields and hash by the same fields, so they work as dictionary keys and set members. Changing a value means making a new one.

A playlist is an entity. Rename it, add three songs, and it is still the same playlist, the one with ID 41 that a user shared last week. Two playlists with identical songs are still two playlists. Entities compare by identity, usually an explicit ID rather than memory address, because the same entity may be loaded twice from a database into two objects that must compare equal.

Mixing these up causes real bugs. Value equality on a mutable entity breaks hash tables: put a playlist in a set, add a song, and the set can no longer find it. Identity equality on a value lets duplicates slip through, because a freshly parsed track never matches a stored one.

Equality and hashing per language

Copy semantics

In Python and JavaScript, b = a copies a reference: there is still one object. In Go, b := a on a struct copies the fields, which looks like a full copy until a field is a slice or map, whose header is copied but whose backing storage is shared. Shallow copies (copy.copy, {...obj}, slices.Clone of pointers) copy one level. The Prototype lesson covers deep copies of graphs; for everyday classes the rule is simpler. Values are immutable, so sharing them is safe and copying them is pointless. Entities own their mutable parts, and a "copy" of an entity is usually a new entity with a new ID and its own collections, such as "duplicate playlist".

Reviewing agent-written code

An agent asked for a Playlist class will produce public fields, a constructor that stores whatever it is given, and perhaps a plain @dataclass, which compares playlists by content and makes them unhashable. Ask for written invariants with a test per invariant, frozen=True on values, ID-based equality on entities, and a test that mutating a returned collection does not change the object.

The same shape elsewhere

Value versus entity is the duplicates problem shape. Event deduplication, idempotency keys and cache keys all ask when two things are the same thing. A cache keyed on a mutable object and an idempotency key built from fields that change on retry are the same bug.

Down the stack

Layer 1The contract

A class promises that every instance reachable by callers satisfies its invariants, both after construction and after every method call, including failed ones. Value types promise that equality is reflexive, symmetric and transitive, that equal instances have equal hashes, and that both are stable for the object's lifetime. Entities promise that equality follows identity and does not change when fields change. A class documents whether returned collections are copies or views, and whether copying an instance shares anything.

Layer 2The mechanism

CPython represents every object as a PyObject header (a reference count and a pointer to the type) followed by type-specific data. A normal class instance stores attributes in a dictionary; since PEP 412, instances of one class share a single keys table and store only values, and 3.11 and later can keep those values inline in the object until something forces a real dict. __slots__ replaces the dictionary with fixed offsets, implemented as descriptors on the class. Attribute lookup walks the type's method resolution order (MRO); a per-type cache tagged with a version number skips the walk, and the 3.11 specialising interpreter rewrites hot LOAD_ATTR instructions into variants such as LOAD_ATTR_INSTANCE_VALUE that read a fixed slot after one type check.

V8 gives each object a pointer to a hidden class (V8 calls it a map) that records property names and offsets. Objects built by the same constructor, assigning fields in the same order, share a map, and inline caches at each property access remember "this map means offset 16". Adding a property later, or in a different order, creates a new map and can turn a fast monomorphic call site into a slow megamorphic one.

Go has no object header. A struct is its fields, laid out in declaration order with alignment padding. Methods are ordinary functions with the receiver as the first argument, resolved at compile time; dynamic dispatch exists only through interface values, which are two words: an itab pointer (type plus method table) and a data pointer.

Layer 3The machine

Object size is mostly headers and pointers. On 64-bit CPython a plain instance with three attributes costs roughly 100 to 200 bytes including its dictionary or inline values, plus 16 bytes of garbage collector header; with __slots__ it is about 56 to 72 bytes. Small objects come from pymalloc pools of fixed size classes in 8-byte steps up to 512 bytes, so a 57-byte request uses a 64-byte block. A JVM object has a 12-byte header with compressed class pointers, padded to a multiple of 8. A V8 object has a map pointer, a properties pointer and an elements pointer before its in-object fields, each 4 bytes with pointer compression.

Go's layout makes padding visible. struct{ a bool; b int64; c bool } is 24 bytes, because b must start at an 8-byte boundary and the struct size rounds up to 8; reordering to struct{ b int64; a, c bool } gives 16 bytes. Across ten million elements in a slice that is 80 MB saved, and fewer cache lines touched per scan. Allocation cost differs too: Go's escape analysis keeps non-escaping structs on the stack at zero allocation cost, V8 bump-allocates new objects in a young generation in a few nanoseconds, and CPython pays roughly 50 to 100 ns to create an instance through __init__.

Why it's built this way

  1. Data that any code can modify ends up invalid somewhere, so data is bundled with the only operations allowed to change it.
  2. Checking rules everywhere is impossible to maintain, so the constructor establishes them once and methods preserve them, which lets the rest of the code assume validity.
  3. Some things are defined by their contents and others by their history, so languages and libraries distinguish value equality from identity, and hash tables require the two to agree with hashing.
  4. Dynamic languages cannot know an object's shape at compile time, so runtimes recover it at run time with shared key tables, hidden classes and inline caches.
  5. Every header, pointer and padding byte is multiplied by the number of instances, so compact layouts (__slots__, field ordering, value types) matter only when instances run into millions.

Diagram

classDiagram
  class Track {
    +title: str
    +artist: str
    +seconds: int
    +equals(other) bool
    +hash() int
  }
  class Playlist {
    +id: str
    -name: str
    -max_seconds: int
    -tracks: list~Track~
    +add(track)
    +remove(track) bool
    +tracks() list~Track~
    +rename(name)
    +copy_as(new_id) Playlist
  }
  Playlist "1" o-- "*" Track : holds values

Worked example

A podcast platform keeps 12 million episode records in memory in a ranking service, each a Python object with eight attributes. With ordinary instances, tracemalloc showed about 230 bytes per object plus its strings: 2.8 GB of objects before any string data, and the service needed 6 GB hosts. Garbage collection passes took 900 ms because the collector walks every container object.

The team made the episode class a @dataclass(frozen=True, slots=True), since ranking only reads episodes and updates arrive as whole new records. Objects dropped to about 104 bytes, saving 1.5 GB. Because the class was now a hashable value, they also deduplicated identical episodes syndicated under different feeds through a dictionary keyed by the episode itself, which removed another 9% of objects. Frozen instances made the deduplication safe, since nobody could mutate a shared episode.

Trade-offs

OptionStrengthsWeaknessesChoose when
Plain dict or mapNo ceremony; easy to serialiseNo invariants; typos create new keysThrowaway scripts, data in transit
Immutable value classSafe to share and hash; trivially thread-safeNew object per changeMoney, coordinates, tracks, IDs, config
Mutable entity with ID equalityModels things that change over timeMust guard invariants in every methodAccounts, orders, playlists, sessions
Class with __slots__ or packed structLess memory, faster attribute accessNo dynamic attributes; harder to extendMillions of instances
Record or dataclass generatorEquality, hash and repr generated consistentlyDefaults (mutable, eq by value) may be wrong for entitiesMost value types

Pitfalls in production

Leadership lens

Check yourself

  1. Why does a method that appends first and validates second break the class contract even though it raises an error?

    Show answer

    The object has already been mutated into an invalid state, and the caller may catch the error and keep using it. Checks must happen before any mutation so a failed call leaves the object unchanged.

  2. Why should a playlist compare by ID rather than by its tracks?

    Show answer

    A playlist is an entity whose contents change over time; comparing by contents would make its hash change on every edit and would treat two different playlists with the same songs as one.

  3. A Go struct holds a []string field. What happens to b := a and to using the struct as a map key?

    Show answer

    The assignment copies the slice header, so a and b share the backing array. A struct with a slice field is not comparable, so it cannot be a map key and will not compile as one.

  4. When is __slots__ worth adding?

    Show answer

    When a class has a fixed set of attributes and you hold enough instances (hundreds of thousands or more) that the per-instance dictionary shows up in memory profiles.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →