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
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
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
Cartdirectly. - UI and domain change for different reasons; now they live in different classes.
Steps
- Create the domain class, empty, and give the UI a reference to it.
- Add a subscription mechanism to the domain class (callback list, event emitter, or your framework's store).
- 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.
- Move the business logic that computes from that field into the domain class with Move Method. Run the tests.
- 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.
Related
- Fixes Large Class and Divergent Change in UI classes.
- The sync mechanism is Observer.
- Built from Move Field and Move Method.
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
- ObserverLet an object announce its changes to a list of subscribers it does not know, so new reactions plug in without editing the source of the change.
- Extract ClassSplit a class that does two jobs by moving one job's fields and methods into a new class it holds a reference to.
- Move FieldMove a field to the class that uses it most, updating every reader and writer to reach it there.
- 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.