Skip to content
aipatterns.dev

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

TypeScript
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

TypeScript
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

  • Device no longer imports Account, which breaks a module cycle.
  • register can't leave the two sides out of sync.
  • Device objects serialize cleanly and can be reused for unowned stock.

Steps

  1. Find every reader of the back-reference (device.owner). Include templates, serializers and tests.
  2. 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.
  3. When no reader remains, remove the code that assigns the back-reference. Run the tests.
  4. 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.

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

See also

Original writing and examples. Topic coverage follows Refactoring.Guru, which explains this topic with its own illustrations.