Association between classes
Open in the interactive app: practice, quiz, mock interview →In one minute
- An association says that objects of one class know about objects of another and can reach them at runtime. In code it is a reference: a field, a collection of references, or a stored id.
- Multiplicity (
1,0..1,0..*,1..5) states how many objects sit at each end. It is a rule, and something in the code has to enforce it. - Navigability says which side can find the other. One-way links are cheap and simple; two-way links are convenient to read and expensive to keep correct.
- A bidirectional association stores the same fact twice. Every write must update both copies together, or the model contradicts itself.
- In review, ask which query needs each reverse pointer and who keeps the two sides in sync.
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
- Objects that cooperate need to find each other, so they store references or ids.
- Following a reference is cheap and searching is not, so frequent queries get a stored link in their direction.
- Two directions mean two copies of one fact, so writes must go through one place that updates both.
- 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
endWorked 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
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| One-way reference | One place to write, no sync bugs | Reverse query needs a scan or index | Reverse lookups are rare |
| Two-way references | Fast navigation both ways | Two writes per change, cycles, lock ordering | Both directions are hot paths in one process |
| Stored ids plus repository | Works across processes and databases | Lookup cost, ids can dangle | Objects live in different services or tables |
| Link object (join table) | Holds relationship data, one source of truth | Extra class and extra hop | The link has its own attributes or history |
Pitfalls in production
- Public setters on both ends, so a caller updates one side only. Fix: private mutators and one pair of
link/unlinkmethods. - Capacity enforced only in memory while several instances write. Fix: enforce it in the database with a conditional update or constraint.
- Deadlock from inconsistent lock order across two objects. Fix: always lock in a global order, such as ascending id.
- ORM lazy loading an association in a loop, issuing one query per row. Fix: eager-load the association for that query or batch the lookups.
Leadership lens
- Every bidirectional association is a consistency obligation. In design review, ask which query needs the reverse direction and what it would cost to compute instead.
- Make multiplicity rules part of the spec and the schema, not just the diagram. The diagram does not run in production.
- Name the owner of each relationship when two teams' objects are linked. Unowned links drift, and drift is found by customers.
- Budget a reconciliation check for any fact stored twice.
Check yourself
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.
In CPython, a
Studentand aSectionreference 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.
A
section.room_idforeign 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_idis indexed.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
- UML Class Diagram Tutorial (Lucid Software): a walkthrough of class diagram notation, including associations and multiplicity.
Free resources
- Association (Wikipedia): background on the UML definition, roles, multiplicity and navigability.
- Foreign Key Mapping (Martin Fowler): how a one-to-many association maps onto a foreign key column.
- Association Table Mapping (Martin Fowler): mapping many-to-many associations through a link table.
- weakref (Python docs): references that do not keep their target alive, for back-pointers and caches.
- gc (Python docs): the cyclic collector that reclaims objects which reference each other.