Engineering Atlas › Low-Level Design

Association between classes

10 min read · last reviewed 2026-10-17

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

In one minute

How it works

The problem: a fact stored in two places

A registration service gives each Student a list of sections and each Section a list of students, because the transcript page walks one way and the roster the other. Months later a section shows 31 students on its roster and 32 on its seat counter: a drop handler updated the student, then returned early on a validation error before touching the section. Nothing crashed. The data just stopped agreeing with itself. That is the core of association: one relationship, possibly stored in several places, with rules about how many and in which direction.

References are the implementation

An association becomes one of three things in code. A direct reference (self.sections: set[Section]) is fast to follow but ties both objects to one process. A stored id resolved through a repository crosses process and database boundaries but needs a lookup and can dangle. A link object (Enrollment(student, section, enrolled_at)) is right when the relationship carries its own data; if a grades dict keyed by section appears on the student, the association wants to be a class.

Multiplicity is a constraint, not a label

A UML class diagram might show Student 0..* --- 0..6 Section: a student takes at most six sections, a section has any number of students. Add a capacity of 30 and the section end becomes 0..30. A list accepts a seventh item without complaint, so multiplicity must be checked in the operation that creates the link, before either side changes, and in the database too (a unique index or a conditional counter update) if more than one process writes.

Lower bounds matter too: 1 forces the other object into the constructor, while 0..1 means every reader must handle an empty field.

Navigability: pick the directions you query

A one-way association from Section to Room has one place to write and no consistency problem, but "which sections use room 204?" needs a scan or an index. Make it two-way only when a frequent query needs it. Databases phrase the same choice differently: a foreign key section.room_id gives you navigation from section to room for free and from room to sections only if you index room_id. A many-to-many link becomes a join table, and you index both columns if both directions are queried.

Bidirectional consistency

If both sides hold references, make one pair of link and unlink methods the only way to change either side, and have them validate everything before mutating anything. In JPA only the owning side of a relationship is written on flush; changing just the inverse side is silently ignored. SQLAlchemy's back_populates takes the opposite approach and updates the in-memory inverse for you.

When not to bother

If an object only uses another during a call, there is no lasting link; that is a dependency, not an association. Skip the reverse pointer when the reverse query is rare: compute it on demand and keep one source of truth.

Reviewing agent-written code

Generated models often mirror every foreign key as two collections with public setters. Check that one method creates each link and one removes it, that multiplicity is checked before any mutation, and that each reverse direction serves a named query.

The same shape elsewhere

A bidirectional association is a denormalised fact. The same problem appears with a search index beside a primary database or a follower count beside a followers table. Each copy needs a single writer path, or a job that detects and repairs drift.

Down the stack

Layer 1The contract

A navigable end promises that you can reach the associated objects, and that their count satisfies the multiplicity whenever a caller can observe it. A bidirectional association adds a symmetry invariant: b in a.items if and only if a in b.owners. Outside the link methods, no caller may ever see one half of a link.

Layer 2The mechanism

In Python a reference is a pointer to a PyObject, and storing it increments the target's reference count. Two objects that point at each other form a cycle, so their counts never reach zero when the program drops them; CPython's cyclic collector finds such groups by subtracting internal references from counts, then frees them. weakref.WeakSet stores back-pointers that do not count. Go and the JVM use tracing collectors that mark what is reachable from roots, so cycles cost nothing special.

Collections hold the many end. A Go map[*Section]struct{} gives constant-time membership and removal; a slice gives order and cheap iteration but linear removal. In a relational database the many end is a foreign key column or a join table with a composite primary key, and navigation in a direction is only fast if an index exists for it.

Layer 3The machine

A reference is 8 bytes on a 64-bit machine, or 4 bytes with the JVM's compressed oops on heaps up to about 32 GB, and with pointer compression in Chrome's V8. Following one that misses cache costs roughly 80 to 120 ns from DRAM, so iterating 10,000 associated objects scattered across the heap can take about a millisecond even when the work per object is trivial. Each map entry costs tens of bytes beyond the pointer, and a two-way association pays it on both sides.

Concurrency is where bidirectional links hurt. Updating two objects atomically needs one lock covering both, or two locks taken in a fixed order such as ascending id. If enroll locks student then section and drop locks section then student, two threads can deadlock.

Why it's built this way

  1. Objects that cooperate need to find each other, so they store references or ids.
  2. Following a reference is cheap and searching is not, so frequent queries get a stored link in their direction.
  3. Two directions mean two copies of one fact, so writes must go through one place that updates both.
  4. Copies across threads or processes can be observed halfway through, so the update needs a lock or a transaction around both halves.

Diagram

sequenceDiagram
  participant C as Caller
  participant E as enroll()
  participant S as Student
  participant K as Section
  C->>E: enroll(ana, CS101)
  E->>S: already linked? under limit?
  E->>K: seat free?
  alt any check fails
    E-->>C: error, nothing changed
  else all checks pass
    E->>S: add CS101
    E->>K: add ana
    E-->>C: ok
  end

Worked example

A university has 40,000 students in 2,500 sections, about 200,000 links. A two-way model stores each twice; at roughly 40 bytes per map entry, about 16 MB. Memory is not the cost.

The cost is opening hour, when about 3,000 requests per second hit mostly the 50 most popular sections. The first implementation checked the seat count, then added each side under its own object lock. Two requests for the last seat both passed the check, and 38 sections ended over capacity. Reconciliation also found 112 transcripts listing a section that had no record of the student, from timeouts between the two adds.

The fix moved the whole operation into one place: a single database transaction that inserts into enrollment(student_id, section_id) with a unique key and decrements section.seats_left with WHERE seats_left > 0. The in-memory objects became read caches built from that table, and mismatches dropped to zero.

Trade-offs

OptionStrengthsWeaknessesChoose when
One-way referenceOne place to write, no sync bugsReverse query needs a scan or indexReverse lookups are rare
Two-way referencesFast navigation both waysTwo writes per change, cycles, lock orderingBoth directions are hot paths in one process
Stored ids plus repositoryWorks across processes and databasesLookup cost, ids can dangleObjects live in different services or tables
Link object (join table)Holds relationship data, one source of truthExtra class and extra hopThe link has its own attributes or history

Pitfalls in production

Leadership lens

Check yourself

  1. Why does a two-way association need its multiplicity checks to run before either side is changed?

    Show answer

    If a check fails after one side was updated, the link exists in one direction only and the symmetry invariant is broken. Validating first means a failure leaves both sides untouched.

  2. In CPython, a Student and a Section reference each other and the program drops both. When is their memory freed?

    Show answer

    Not by reference counting, because each keeps the other's count above zero. The cyclic garbage collector frees them on a later collection pass.

  3. A section.room_id foreign key exists with no index. Which direction of the association is cheap?

    Show answer

    Section to room is a primary key lookup. Room to sections requires a scan of the section table until room_id is indexed.

  4. When should an association become its own class?

    Show answer

    When the relationship has attributes of its own, such as a grade, a role or a timestamp, or needs history. Those facts belong to the link, not to either end.

Watch

Free resources

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