Loading learning content...
Knowing that Order and Customer are related is fundamental. But a deeper question shapes your design: Who needs to find whom? Does an order need to access its customer? Does a customer need to access their orders? Both? Neither?
Navigability defines the direction of traversal in a relationship. It determines which object holds a reference to the other, directly impacting API design, data access patterns, memory usage, and maintenance complexity.
Get navigability wrong, and you'll either have circular dependencies creating memory leaks and testing nightmares, or you'll find yourself constantly traversing through intermediate objects because you can't get from A to B directly.
By the end of this page, you will understand unidirectional vs bidirectional navigation, know when to use each, master techniques for managing bidirectional relationships, and recognize the trade-offs involved.
Navigability describes which direction(s) you can traverse a relationship in code.
Types of Navigability:
Unidirectional (→): Only one class holds a reference to the other
Bidirectional (↔): Both classes hold references to each other
Non-navigable: The relationship exists conceptually but neither class holds a direct reference
| Question | Unidirectional | Bidirectional |
|---|---|---|
| Who needs access? | Only one side queries the other | Both sides need to query each other |
| Complexity | Simple—one reference to manage | Complex—must keep references in sync |
| Coupling | Lower—only one class depends on other | Higher—both classes depend on each other |
| Memory | Lower—fewer references | Higher—references on both sides |
| Use case | Parent→children, Service→dependency | Mutual associations, networks |
Unidirectional navigation is the default choice and should be preferred unless there's a compelling reason for bidirectionality.
When to Use Unidirectional:
Benefits:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
// Unidirectional: Order → OrderItems// Order knows about its items, items don't need to know about order class OrderItem { // No reference back to Order - unidirectional constructor( public readonly sku: string, public readonly name: string, public readonly quantity: number, public readonly unitPrice: number ) {} getSubtotal(): number { return this.quantity * this.unitPrice; }} class Order { private readonly items: OrderItem[] = []; constructor( public readonly orderId: string, public readonly customerId: string ) {} // Order navigates TO items addItem(sku: string, name: string, qty: number, price: number): void { this.items.push(new OrderItem(sku, name, qty, price)); } getItems(): ReadonlyArray<OrderItem> { return this.items; } getTotal(): number { return this.items.reduce((sum, item) => sum + item.getSubtotal(), 0); } getItemCount(): number { return this.items.length; }} // Unidirectional: Service → Repository// OrderService uses OrderRepository; repository doesn't know about service interface OrderRepository { findById(id: string): Promise<Order | null>; save(order: Order): Promise<void>; findByCustomerId(customerId: string): Promise<Order[]>;} class OrderService { // Service navigates TO repository constructor(private readonly orderRepository: OrderRepository) {} async createOrder(customerId: string): Promise<Order> { const order = new Order(`ORD-${Date.now()}`, customerId); await this.orderRepository.save(order); return order; } async getCustomerOrders(customerId: string): Promise<Order[]> { return this.orderRepository.findByCustomerId(customerId); }}Always start with unidirectional navigation. Only add bidirectionality when you have a concrete use case that requires it. It's easy to add a back-reference later but hard to remove one that code has come to depend on.
Bidirectional navigation allows traversal in both directions but comes with significant complexity.
When Bidirectional is Needed:
The Core Challenge: With bidirectional relationships, you must keep both sides synchronized. If A references B, and B references A, adding or removing from one side must update the other. This is the source of many bugs.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
// Bidirectional: Employee ↔ Department// Employees need their department; departments need their employees class Employee { private department: Department | null = null; constructor( public readonly id: string, public readonly name: string ) {} // INTERNAL: Only Department should call this _setDepartment(dept: Department | null): void { this.department = dept; } getDepartment(): Department | null { return this.department; } // PUBLIC: Use this to transfer departments transferTo(newDepartment: Department | null): void { if (this.department === newDepartment) return; // Remove from old department if (this.department) { this.department._removeEmployee(this); } // Add to new department if (newDepartment) { newDepartment._addEmployee(this); } this.department = newDepartment; }} class Department { private readonly employees = new Set<Employee>(); constructor( public readonly id: string, public readonly name: string ) {} // INTERNAL: Only Employee should call these _addEmployee(employee: Employee): void { this.employees.add(employee); employee._setDepartment(this); } _removeEmployee(employee: Employee): void { this.employees.delete(employee); employee._setDepartment(null); } // PUBLIC: Use this to hire hire(employee: Employee): void { if (this.employees.has(employee)) return; employee.transferTo(this); } getEmployees(): ReadonlySet<Employee> { return this.employees; } getEmployeeCount(): number { return this.employees.size; }} // Usage preserves consistencyconst engineering = new Department("D1", "Engineering");const alice = new Employee("E1", "Alice"); engineering.hire(alice);console.log(alice.getDepartment()?.name); // "Engineering"console.log(engineering.getEmployeeCount()); // 1 const product = new Department("D2", "Product");alice.transferTo(product);console.log(engineering.getEmployeeCount()); // 0console.log(product.getEmployeeCount()); // 1The biggest challenge with bidirectional relationships is keeping both sides consistent. Here are proven patterns:
_setX or package-private) to indicate methods that shouldn't be called directly by client code.12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
// Pattern: Owning Side (Author owns the relationship to Books)class Book { private _author: Author | null = null; constructor( public readonly isbn: string, public readonly title: string ) {} get author(): Author | null { return this._author; } // Internal: Only Author should call this _setAuthor(author: Author | null): void { this._author = author; }} class Author { private readonly _books = new Set<Book>(); constructor( public readonly id: string, public readonly name: string ) {} get books(): ReadonlySet<Book> { return this._books; } // Author is the OWNING SIDE - manages both directions addBook(book: Book): void { if (this._books.has(book)) return; // Remove from previous author const previousAuthor = book.author; if (previousAuthor) { previousAuthor._books.delete(book); } // Add to this author this._books.add(book); book._setAuthor(this); } removeBook(book: Book): void { if (!this._books.has(book)) return; this._books.delete(book); book._setAuthor(null); }} // Client code only uses Author's methodsconst author = new Author("1", "J.K. Rowling");const book = new Book("978-0-7475-3269-9", "Harry Potter"); author.addBook(book); // Both sides updatedconsole.log(book.author?.name); // "J.K. Rowling"console.log(author.books.size); // 1Bidirectional relationships create circular references. While modern garbage collectors handle these for memory, they cause other problems:
12345678910111213141516171819202122232425262728293031323334353637383940
// Problem: JSON serialization of circular referencesclass Department { employees: Employee[] = []; toJSON() { return { name: this.name, employees: this.employees }; }}class Employee { department: Department; toJSON() { return { name: this.name, department: this.department }; }}// JSON.stringify(dept) → infinite loop! // Solution 1: Custom toJSON that breaks the cycleclass Employee { department: Department; toJSON() { return { name: this.name, departmentId: this.department?.id // Reference by ID, not object }; }} // Solution 2: Use a library like flatted for circular JSONimport { stringify, parse } from 'flatted';const json = stringify(circularObject); // Handles cycles // Solution 3: Exclude back-references in serializationclass Department { @Exclude() // Decorator to exclude from serialization employees: Employee[] = [];} // Solution 4: Make toString() cycle-awareclass Employee { toString(): string { // Don't include department details, just ID return `Employee(${this.name}, dept:${this.department?.id})`; }}UML provides specific notation for navigability:
| Symbol | Meaning | Example |
|---|---|---|
| A——→B | Unidirectional: A navigates to B | Order→Customer |
| A←——→B | Bidirectional: Both navigate | Employee↔Department |
| A——×B | Non-navigable: A cannot reach B | A×——B (neither can reach other) |
| A——B (no arrows) | Unspecified/both ways | Early design phase |
In UML, an arrowhead indicates navigability. The class at the arrow's origin can navigate to the class at the arrowhead. An 'X' explicitly marks a non-navigable end. No symbol means navigability is unspecified.
You now understand navigability and its implications for design. In the final page of this module, we'll cover relationship validation—ensuring your relationships are correct, complete, and consistent.