Loading learning content...
Individual entities, no matter how well-designed, are useless in isolation. The power of object-oriented design emerges when entities connect through well-defined relationships. This page establishes the formal connections that enable our Library Management System to function as a cohesive whole.
Relationship Types in OOP:
We'll work with four primary relationship types:
Each relationship type has distinct semantics that affect ownership, lifecycle management, and navigation patterns.
By the end of this page, you will be able to: • Identify and model different types of class relationships • Choose appropriate relationship types based on ownership semantics • Design navigable relationships with proper directionality • Understand aggregate boundaries and their implications • Create UML class diagrams representing the complete system
The relationship between Book and BookCopy is composition—the strongest form of "has-a" relationship.
Why Composition?
BookCopy cannot exist without its parent BookBook is removed from the catalog, its copies are also removedBook is responsible for creating and managing its copiesBookCopy has no meaning outside the context of its BookCardinality: One Book → Many BookCopies (1:*)
Lifecycle Coupling:
BookCopy is created through Book.addCopy()BookCopy is destroyed when Book.removeCopy() is calledBook cascades to all its BookCopy instances12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
class Book { private readonly copies: BookCopy[] = []; /** * Factory method for creating copies * Only Book can create BookCopy instances */ addCopy(barcode: string, rackLocation: string): BookCopy { const copyId = this.generateCopyId(); // BookCopy constructor is package-private in practice // Only Book can instantiate BookCopy const copy = new BookCopy(copyId, barcode, this, rackLocation); this.copies.push(copy); return copy; } /** * Removes a copy - enforces business constraints */ removeCopy(copyId: string): void { const copy = this.findCopy(copyId); if (!copy) { throw new BookCopyNotFoundError(copyId); } // Composition lifecycle: can't remove if "in use" if (copy.isCurrentlyLoaned()) { throw new InvalidOperationError( 'Cannot remove a copy that is currently loaned' ); } const index = this.copies.indexOf(copy); this.copies.splice(index, 1); // In some languages, we might explicitly dispose of 'copy' here // In JS/TS, it becomes eligible for garbage collection } private generateCopyId(): string { return `${this.id}-COPY-${Date.now()}-${this.copies.length + 1}`; }} /** * BookCopy holds a reference back to its parent Book. * This reference is set at construction and never changes. */class BookCopy { private readonly book: Book; // Immutable parent reference constructor( id: string, barcode: string, book: Book, // Required - cannot exist without Book rackLocation: string ) { this.id = id; this.barcode = barcode; this.book = book; this.rackLocation = rackLocation; this.status = BookCopyStatus.AVAILABLE; } /** * Always has access to parent Book * Enables navigation: copy.getBook().getTitle() */ getBook(): Book { return this.book; }}In composition, the parent class acts as a factory for child objects. This gives the parent control over child lifecycle and ensures children are never orphaned. The bidirectional reference (Book→BookCopy and BookCopy→Book) enables convenient navigation but requires careful management to avoid inconsistencies.
The relationship between Member and Loan is aggregation—a weaker "has-a" relationship where both entities have independent significance.
Why Aggregation (not Composition)?
Loan has identity and meaning beyond its association with a MemberLoan records persist in history even if a Member is deactivatedLoan connects multiple entities (Member, BookCopy, Librarian)—it's not exclusively owned by MemberLoans independently (overdue reports, audit trails)Cardinality: One Member → Many Loans (1:*)
Lifecycle Independence:
Loans remain in history when Member is closedMember references its "active" loans but doesn't own the full loan lifecycleLoan creation is coordinated by a service, not by Member alone123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
class Member { private readonly activeLoans: Loan[] = []; private readonly loanHistory: Loan[] = []; /** * Adds an existing Loan to member's active loans * Note: Member doesn't CREATE the loan, just receives it */ addLoan(loan: Loan): void { // Validate business rules const eligibility = this.canBorrow(); if (!eligibility.eligible) { throw new BorrowingNotAllowedError(eligibility.reason); } // Member doesn't own Loan creation - just tracks the association this.activeLoans.push(loan); } /** * Moves loan from active to history * Loan continues to exist - it's just in a different collection */ completeLoan(loan: Loan): void { const index = this.activeLoans.indexOf(loan); if (index === -1) { throw new LoanNotFoundError( 'Loan not found in active loans' ); } // Move to history, not delete this.activeLoans.splice(index, 1); this.loanHistory.push(loan); } /** * Gets active loans (returns copies to protect encapsulation) */ getActiveLoans(): ReadonlyArray<Loan> { return [...this.activeLoans]; }} /** * Loan has references to Member, BookCopy, and Librarian * It's associated with all three, owned by none */class Loan { private readonly member: Member; // Who borrowed private readonly bookCopy: BookCopy; // What was borrowed private readonly processedBy: Librarian; // Who processed constructor( id: string, member: Member, bookCopy: BookCopy, processedBy: Librarian ) { this.id = id; this.member = member; this.bookCopy = bookCopy; this.processedBy = processedBy; // ... initialization } // Loan can be queried independently // No entity "owns" Loan exclusively}Ask yourself: If the parent is deleted, should the child be deleted too?
• Book → BookCopy: Yes, copies are meaningless without their book → Composition • Member → Loan: No, loan history should persist for auditing → Aggregation • Loan → BookCopy: No, the copy exists independently → Association
Reservations connect Members to Books in a queue structure. This relationship is an association with additional queue semantics.
Why Association?
Reservation connects a Member to a Book, but neither owns itDesign Challenge: Who Owns the Queue?
We have options:
Book maintains a reservation queueReservationQueue entity per bookReservationService manages all queuesFor our design, we'll have Book hold its reservation queue directly—it's simple and the queue doesn't have independent identity.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
class Book { private readonly reservations: Reservation[] = []; /** * Adds a reservation to the queue * New reservations go to the back */ addReservation(reservation: Reservation): void { // Prevent duplicate reservations by same member const existingReservation = this.reservations.find( r => r.getMember().getId() === reservation.getMember().getId() && r.getStatus() === ReservationStatus.PENDING ); if (existingReservation) { throw new DuplicateReservationError( 'Member already has a pending reservation for this book' ); } this.reservations.push(reservation); } /** * Gets the next reservation in queue (FIFO) * Returns null if no pending reservations */ getNextPendingReservation(): Reservation | null { return this.reservations.find( r => r.getStatus() === ReservationStatus.PENDING ) || null; } /** * Checks if there are pending reservations * Used to determine if renewals are allowed */ hasPendingReservations(): boolean { return this.reservations.some( r => r.getStatus() === ReservationStatus.PENDING || r.getStatus() === ReservationStatus.READY ); } /** * Processes the queue when a copy is returned * Notifies the next member and places book on hold */ processReservationQueue(returnedCopy: BookCopy): Reservation | null { const nextReservation = this.getNextPendingReservation(); if (nextReservation) { nextReservation.markReady(); returnedCopy.placeOnHold(); // Trigger notification (handled by service layer) return nextReservation; } return null; } /** * Removes expired or cancelled reservations */ cleanupReservations(): void { // Filter to keep only active reservations const activeStatuses = [ ReservationStatus.PENDING, ReservationStatus.READY ]; // Move inactive to separate collection if needed for history this.reservations .filter(r => !activeStatuses.includes(r.getStatus())) .forEach(r => {/* Archive logic */}); // Keep only active this.reservations.splice( 0, this.reservations.length, ...this.reservations.filter( r => activeStatuses.includes(r.getStatus()) ) ); }} /** * A reservation connects a Member who wants a Book */class Reservation { private readonly member: Member; private readonly book: Book; private status: ReservationStatus; private readonly createdAt: Date; private notifiedAt: Date | null = null; private expiresAt: Date | null = null; constructor(id: string, member: Member, book: Book) { this.id = id; this.member = member; this.book = book; this.status = ReservationStatus.PENDING; this.createdAt = new Date(); // Also add to member's reservations member.addReservation(this); } /** * Queue position based on creation time */ getQueuePosition(): number { return this.book.getReservations() .filter(r => r.getStatus() === ReservationStatus.PENDING) .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) .indexOf(this) + 1; }}Let's visualize the complete class structure with all entities and their relationships. This diagram shows how the entities interconnect to form a cohesive system.
Notation Guide: • Filled diamond (◆) = Composition (Book ◆── BookCopy) • Empty diamond (◇) = Aggregation (Member ◇── Loan) • Arrow (→) = Association/Dependency (Loan → BookCopy) • Numbers = Cardinality (1, 0.., 1..)
The diagram shows Book as an aggregate root containing BookCopies, while Loan connects Member, BookCopy, and Librarian through associations.
In Domain-Driven Design (DDD), an aggregate is a cluster of entities treated as a single unit for data changes. One entity in the aggregate is the aggregate root—the only entry point for external access.
Why Aggregates Matter:
Library System Aggregates:
| Aggregate Root | Contained Entities | Invariants Protected |
|---|---|---|
| Book | BookCopy, (Reservation reference) | Copy count consistency, availability accuracy |
| Member | (Loan references), (Reservation references) | Borrowing limit, fine threshold, status consistency |
| Loan | (Referenced by Member, BookCopy) | Date validity, status transitions, fine calculation |
| Librarian | None | Permission consistency |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
/** * Book is an Aggregate Root. * External code must go through Book to access BookCopies. */class Book { private readonly copies: BookCopy[] = []; // ✅ Good: Access copies through aggregate root methods findAvailableCopy(): BookCopy | null { return this.copies.find(c => c.isAvailable()) || null; } getCopyByBarcode(barcode: string): BookCopy | null { return this.copies.find(c => c.getBarcode() === barcode) || null; } // ✅ Good: Modifications go through aggregate root addCopy(barcode: string, location: string): BookCopy { // Business logic here const copy = new BookCopy(/*...*/); this.copies.push(copy); return copy; } // ✅ Good: Returns read-only view getCopies(): ReadonlyArray<BookCopy> { return [...this.copies]; }} /** * BookRepository works with Book aggregate root. * Never provides direct access to BookCopy. */interface BookRepository { findById(id: string): Promise<Book | null>; findByIsbn(isbn: string): Promise<Book | null>; save(book: Book): Promise<void>; // ❌ Wrong: This bypasses the aggregate root // findCopyByBarcode(barcode: string): Promise<BookCopy | null>; // ✅ Correct: Search through Book, get copy from result findByBarcode(barcode: string): Promise<Book | null>;} // Usage example:async function checkoutBook(barcode: string, memberId: string) { // Get aggregate root first const book = await bookRepository.findByBarcode(barcode); if (!book) throw new BookNotFoundError(); // Access copy through aggregate root const copy = book.getCopyByBarcode(barcode); if (!copy || !copy.isAvailable()) { throw new CopyNotAvailableError(); } // Proceed with checkout...}When an entity references another aggregate root, store the ID rather than the object reference. This prevents inadvertent modification of foreign aggregates and maintains clear boundaries.
Example: Loan could store memberId: string instead of member: Member, fetching the Member when needed. For our design, we use object references for convenience but treat them as read-only.
Not all relationships need bidirectional navigation. Choosing direction carefully reduces complexity and clarifies dependencies.
Decision Criteria:
| Relationship | Direction | Rationale |
|---|---|---|
| Book → BookCopy | Bidirectional | Book manages copies; Copy needs title for display |
| Book → Author | Book → Author only | Book displays author; Authors rarely need book lists in our use cases |
| Member → Loan | Bidirectional | Member tracks active loans; Loan needs member for fines |
| Loan → BookCopy | Loan → Copy only | Loan references what was borrowed; Copy has currentLoan for status |
| Loan → Librarian | Loan → Librarian only | Loan tracks processor; Librarians don't query their loans |
| Member → Reservation | Bidirectional | Member tracks reservations; Reservation needs member for notification |
| Book → Reservation | Book → Reservation only | Book maintains queue; Reservation has book reference for info |
1234567891011121314151617181920212223242526272829303132333435363738
/** * Author has no back-reference to Book. * If we need "books by author", we query BookRepository. */class Author { private readonly id: string; private name: string; // No books: Book[] here - unidirectional from Book to Author constructor(id: string, name: string) { this.id = id; this.name = name; }} class Book { private authors: Author[] = []; // Book knows its authors addAuthor(author: Author): void { if (!this.authors.includes(author)) { this.authors.push(author); } } getAuthors(): ReadonlyArray<Author> { return [...this.authors]; }} // If needed: "Find all books by author"interface BookRepository { findByAuthor(authorId: string): Promise<Book[]>;} // Usage:const authorBooks = await bookRepository.findByAuthor(author.getId());// No need for author.getBooks() - repository handles this queryWhen relationships are bidirectional, ensure both sides stay synchronized. The standard approach: one side (usually the "owning" side) manages the relationship and updates the other side.
// Book is the owner of Book-Author relationship
book.addAuthor(author); // This method updates both sides
Avoid allowing direct manipulation of the author list from both sides—it leads to inconsistency.
We've established the formal relationships that connect our Library Management System entities:
What's Next:
With entities and relationships defined, we now apply design patterns to handle complex behaviors elegantly. The next page covers:
These patterns will transform our functional but rigid design into a flexible, extensible system.
You now understand how to model relationships between entities using composition, aggregation, and association. You can identify aggregate boundaries, make navigation direction decisions, and create comprehensive class diagrams. Next, we'll apply design patterns to add sophistication and extensibility to our design.