Refactoring techniqueOrganizing Data
Change Bidirectional Association to Unidirectional
Remove a back-reference that no code needs any more, so two classes stop depending on each other.
Problem
Two classes point to each other, but only one direction is used in practice. The unused direction still costs sync code, creates a dependency cycle between modules, complicates serialization and keeps objects alive longer than necessary.
Solution
Find the code that uses the unwanted direction, give it another way to get what it needs, then delete the back-reference and its sync logic.
Before
class Device {
owner: Account | null = null;
constructor(readonly serial: string) {}
notificationEmail(): string | null {
return this.owner?.email ?? null;
}
}
class Account {
private devices = new Set<Device>();
constructor(readonly id: string, readonly email: string) {}
register(device: Device): void {
this.devices.add(device);
device.owner = this;
}
firmwareAlert(version: string): string[] {
return [...this.devices].map((d) => `${d.notificationEmail()}: ${d.serial} needs ${version}`);
}
}After
class Device {
constructor(readonly serial: string) {}
}
class Account {
private devices = new Set<Device>();
constructor(readonly id: string, readonly email: string) {}
register(device: Device): void {
this.devices.add(device);
}
firmwareAlert(version: string): string[] {
return [...this.devices].map((d) => `${this.email}: ${d.serial} needs ${version}`);
}
}Why
Deviceno longer importsAccount, which breaks a module cycle.registercan't leave the two sides out of sync.- Device objects serialize cleanly and can be reused for unowned stock.
Steps
- Find every reader of the back-reference (
device.owner). Include templates, serializers and tests. - For each reader, supply the value another way: pass it as a parameter, read it from the controlling side, or look it up through a repository. Run the tests after each.
- When no reader remains, remove the code that assigns the back-reference. Run the tests.
- Remove the field.
Watch out
- Hidden readers are common: ORM mappings, GraphQL resolvers, admin panels, logging. Search for the field name as a string too.
- If removing the reference forces a slow lookup on a hot path, keep it.
- Persistence: in a relational schema, the foreign key usually lives on the "many" side. Removing the object reference does not mean dropping the column.
- The inverse is Change Unidirectional Association to Bidirectional.
Related
- Fixes Inappropriate Intimacy and removes Dead Code when the link is unused.
- Inverse: Change Unidirectional Association to Bidirectional.
Agent checklist
- Every reader of the back-reference was found and given an alternative.
- No hot path now does an expensive lookup instead.
- Sync code that assigned the back-reference is deleted.
- Database columns and ORM mappings were handled deliberately.
- Tests pass.
Treats these smells
- Inappropriate IntimacyTwo classes reach into each other's private fields and internals. Cut the back-and-forth by moving members or narrowing what each exposes.
- Dead CodeVariables, parameters, branches, methods or classes that nothing uses still sit in the codebase, costing reading time and misleading readers.
See also
- Change Unidirectional Association to BidirectionalAdd a back-reference when each of two classes needs to navigate to the other, and keep both sides in sync through one owner.
- Hide DelegateAdd a method on the server object that forwards to its delegate, so clients stop navigating through it to reach the delegate.
- Move MethodMove a method to the class whose data it uses most, and leave a delegating call or update the callers.
Original writing and examples. Topic coverage follows Refactoring.Guru, which explains this topic with its own illustrations.