Skip to content
aipatterns.dev

Refactoring techniqueOrganizing Data

Duplicate Observed Data

Move domain data out of a UI class into a domain object, and keep the UI in sync with it through observation.

Problem

A UI component stores business data in its own fields and computes business rules in its event handlers. The rules can't be tested without rendering, can't be reused by an API or a background job, and every UI change risks breaking them.

Solution

Create a domain class that owns the data and rules. Have the UI hold a copy of what it displays and subscribe to the domain object for changes. The UI keeps its fields, but the domain object is the source of truth.

Before

TypeScript
class CartWidget {
  private items: { sku: string; price: number; qty: number }[] = [];
  private totalLabel = "";

  onAdd(sku: string, price: number): void {
    const existing = this.items.find((i) => i.sku === sku);
    if (existing) existing.qty += 1;
    else this.items.push({ sku, price, qty: 1 });
    const subtotal = this.items.reduce((s, i) => s + i.price * i.qty, 0);
    const discount = subtotal > 100 ? subtotal * 0.1 : 0;
    this.totalLabel = `$${(subtotal - discount).toFixed(2)}`;
    this.render();
  }

  render(): void { /* draws items and totalLabel */ }
}

After

TypeScript
type Listener = () => void;

class Cart {
  private items = new Map<string, { price: number; qty: number }>();
  private listeners: Listener[] = [];

  subscribe(fn: Listener): void { this.listeners.push(fn); }

  add(sku: string, price: number): void {
    const item = this.items.get(sku) ?? { price, qty: 0 };
    item.qty += 1;
    this.items.set(sku, item);
    this.listeners.forEach((fn) => fn());
  }

  total(): number {
    const subtotal = [...this.items.values()].reduce((s, i) => s + i.price * i.qty, 0);
    return subtotal > 100 ? subtotal * 0.9 : subtotal;
  }
}

class CartWidget {
  private totalLabel = "";

  constructor(private cart: Cart) {
    cart.subscribe(() => this.update());
  }

  onAdd(sku: string, price: number): void { this.cart.add(sku, price); }

  private update(): void {
    this.totalLabel = `$${this.cart.total().toFixed(2)}`;
    this.render();
  }

  render(): void { /* draws items and totalLabel */ }
}

Why

  • The discount rule is testable with no UI.
  • An API endpoint or a second view can use Cart directly.
  • UI and domain change for different reasons; now they live in different classes.

Steps

  1. Create the domain class, empty, and give the UI a reference to it.
  2. Add a subscription mechanism to the domain class (callback list, event emitter, or your framework's store).
  3. For one field, add it to the domain class. Make the UI's handler write to the domain object, and make the UI's update callback copy the value back into its own field. Run the tests.
  4. Move the business logic that computes from that field into the domain class with Move Method. Run the tests.
  5. Repeat field by field. Finally, remove UI fields that no longer need a local copy.

Watch out

  • Modern UI frameworks already separate state from view: React state and stores, Vue reactivity, signals, MVVM bindings. Use the framework's mechanism instead of writing an observer by hand.
  • Unsubscribe when the UI is destroyed, or the domain object keeps it alive and calls into dead views.
  • Notifications fired inside notifications can loop. Keep update callbacks read-only toward the domain.
  • Don't apply when the "data" is purely presentational (which tab is open, scroll position). That belongs in the UI.
  • No direct inverse; collapsing domain back into a UI class is the smell.

Agent checklist

  • Business rules are in the domain class and have tests without rendering.
  • The UI writes through the domain object and reads back via notifications.
  • Subscriptions are removed when the UI component is disposed.
  • The framework's own state mechanism was preferred where one exists.
  • Tests pass.

Treats these smells

See also

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