Domain objects are autonomous state machines with enforced invariants — they are not passive bags of data passed between controller functions. The five SOLID principles that governed the backend billing engine (Parts 1 and 8 of the backend series) apply with equal force on the frontend — but they manifest differently. SRP is not about separating database access from business logic; it is about separating rendering concerns from domain concerns. OCP is not about adding event handlers without modifying a use case; it is about adding new shape renderers without touching the canvas component. The principles are identical — the layer they govern is different.
This article applies all five SOLID principles to the canvas studio — with concrete violations from frontend React codebases and the refactoring that resolves each one.
SRP in frontend engineering is violated most commonly in React components that own too many concerns and in hooks that combine fetching, caching, and transformation.
function CanvasEditor() {
const [activeTool, setActiveTool] = useState('select');
const [zoom, setZoom] = useState(1);
const [showLayerPanel, setShowLayerPanel] = useState(true);
const [showPropertiesPanel, setShowPropertiesPanel] = useState(true);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'z' && e.metaKey) { }
if (e.key === 'z' && e.metaKey && e.shiftKey) { }
if (e.key === 'v') setActiveTool('select');
if (e.key === 'r') setActiveTool('rect');
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, []);
}
Seven reasons this component changes: tool selection changes, zoom behavior changes, panel visibility changes, context menu items change, keyboard shortcuts change, undo/redo logic changes, or the canvas rendering changes.
The fix: one component, one reason to change:
function CanvasEditor({ doc, tools }: { doc: CanvasDocument; tools: ToolController }) {
return (
<div className="canvas-editor">
<Toolbar tools={tools} /> {/* Owns: tool selection UI */}
<LayerPanel doc={doc} /> {/* Owns: layer list rendering */}
<Canvas doc={doc} tools={tools} /> {/* Owns: shape rendering + pointer events */}
<PropertiesPanel doc={doc} /> {/* Owns: selected shape properties */}
<ZoomControls doc={doc} /> {/* Owns: zoom/pan UI */}
</div>
);
}
function useShapeData(id: ShapeId) {
const [loading, setLoading] = useState(true);
const [raw, setRaw] = useState<ApiShapeResponse | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch(`/api/shapes/${id}`)
.then(r => r.json())
.then(data => { setRaw(data); setLoading(false); })
.catch(e => { setError(e.message); setLoading(false); });
}, [id]);
const shape = raw ? parseShapeId(raw.id) && new RectShape(parseShapeId(raw.id), raw.x, raw.y, raw.width, raw.height) : null;
const errorMessage = error ? `Failed to load shape: ${error}` : null;
return { shape, loading, errorMessage };
}
The fix: separate fetch, transform, and error presentation:
function useShapeApi(id: ShapeId) {
return useSWR<ApiShapeResponse>(`/api/shapes/${id}`, fetcher);
}
function parseApiShape(raw: ApiShapeResponse): RectShape {
return new RectShape(parseShapeId(raw.id), raw.x, raw.y, raw.width, raw.height);
}
function useShape(id: ShapeId): { shape: RectShape | null; isLoading: boolean; error: Error | null } {
const { data, error, isLoading } = useShapeApi(id);
return {
shape: data ? parseApiShape(data) : null,
isLoading,
error: error ?? null,
};
}
OCP in frontend is violated most commonly in the shape renderer — every new shape type opens the renderer file to add a new branch.
function ShapeRenderer({ data }: { data: ShapeData }) {
switch (data.type) {
case 'rect': return <RectRenderer data={data as RectShapeData} />;
case 'ellipse': return <EllipseRenderer data={data as EllipseShapeData} />;
case 'text': return <TextRenderer data={data as TextShapeData} />;
}
}
The fix: a renderer registry — open for extension, closed for modification:
type ShapeRendererComponent = React.FC<{ data: ShapeData }>;
const rendererRegistry = new Map<string, ShapeRendererComponent>([
['rect', RectRenderer],
['ellipse', EllipseRenderer],
['text', TextRenderer],
]);
export function registerRenderer(type: string, component: ShapeRendererComponent): void {
rendererRegistry.set(type, component);
}
export function ShapeRenderer({ data }: { data: ShapeData }) {
const Component = rendererRegistry.get(data.type);
if (!Component) return <UnknownShapeRenderer type={data.type} />;
return <Component data={data} />;
}
Adding PathShape rendering:
registerRenderer('path', PathRenderer);
ShapeRenderer.tsx never needs to be opened again.
Already established in Part 7: ToolController.registerTool() allows adding new tools without modifying ToolController. The same pattern applies to export formats, keyboard shortcut handlers, and context menu items.
LSP in frontend manifests when a concrete tool implementation changes the behavioral contract of ITool — breaking code that depends on the interface.
class EyedropperTool implements ITool {
readonly name = 'eyedropper';
readonly cursor = 'crosshair';
onPointerDown(event: ToolPointerEvent, doc: CanvasDocument): void {
const color = doc.sampleColorAt(event.point);
doc.setFillColor(color);
}
onPointerMove(_event: ToolPointerEvent, _doc: CanvasDocument): void {
}
onPointerUp() {} onCancel() {} activate() {} deactivate() {}
}
The ITool contract implies: during onPointerMove, the tool updates the preview state so the canvas reflects the current interaction. EyedropperTool silently violates this by doing nothing — the cursor and preview do not update.
The fix: honor the behavioral contract:
class EyedropperTool implements ITool {
onPointerMove(event: ToolPointerEvent, doc: CanvasDocument): void {
const color = doc.sampleColorAt(event.point);
doc.setPreview({ type: 'color-sample', point: event.point, color });
}
}
The ITool contract test (from the testing strategy in Part 12) catches this:
export function toolContract(makeTool: () => ITool): void {
describe('ITool contract', () => {
it('should update preview during pointer move', () => {
const tool = makeTool();
const doc = new CanvasDocument();
tool.activate(doc);
tool.onPointerDown({ point: point(0, 0), shiftKey: false, ctrlKey: false, altKey: false, pressure: 1 }, doc);
tool.onPointerMove({ point: point(50, 50), shiftKey: false, ctrlKey: false, altKey: false, pressure: 1 }, doc);
const snap = doc.getSnapshot();
const hadEffect = snap.preview !== null || snap.selectedIds.size > 0;
expect(hadEffect).toBe(true);
});
it('should clear preview on deactivate', () => {
const tool = makeTool();
const doc = new CanvasDocument();
tool.activate(doc);
tool.onPointerDown({ point: point(0, 0), shiftKey: false, ctrlKey: false, altKey: false, pressure: 1 }, doc);
tool.deactivate(doc);
expect(doc.getSnapshot().preview).toBeNull();
});
});
}
ISP in frontend is violated when components receive a fat snapshot with all canvas state — most of which they do not use — causing unnecessary re-renders when unrelated state changes.
interface CanvasSnapshot {
shapes: ShapeData[];
selectedIds: Set<ShapeId>;
activeTool: string;
zoom: number;
pan: Point;
preview: PreviewData | null;
canUndo: boolean;
canRedo: boolean;
layerOrder: LayerId[];
fillColor: string;
strokeColor: string;
}
function Toolbar({ snapshot }: { snapshot: CanvasSnapshot }) {
return <ToolButton active={snapshot.activeTool} />;
}
The fix: segregated snapshot interfaces per component role:
interface ToolbarSnapshot {
activeTool: string;
canUndo: boolean;
canRedo: boolean;
}
interface CanvasViewSnapshot {
shapes: ShapeData[];
preview: PreviewData | null;
zoom: number;
pan: Point;
}
interface PropertiesPanelSnapshot {
selectedIds: Set<ShapeId>;
fillColor: string;
strokeColor: string;
}
function Toolbar({ snapshot }: { snapshot: ToolbarSnapshot }) {
return <ToolButton active={snapshot.activeTool} />;
}
The domain object provides narrowed selectors:
class CanvasDocument {
getToolbarSnapshot(): ToolbarSnapshot {
return { activeTool: this.#tools.activeTool.name, canUndo: this.#history.canUndo, canRedo: this.#history.canRedo };
}
getCanvasViewSnapshot(): CanvasViewSnapshot {
return {
shapes: this.#renderOrder.map(id => this.#shapes.get(id)!.toData()),
preview: this.#preview,
zoom: this.#viewport.zoom,
pan: this.#viewport.pan,
};
}
getPropertiesSnapshot(): PropertiesPanelSnapshot {
return { selectedIds: new Set(this.#selectedIds), fillColor: this.#fillColor, strokeColor: this.#strokeColor };
}
}
Each component subscribes to its specific selector — useSyncExternalStore (Part 11) uses the selector as the getSnapshot argument, so re-renders are gated on the component's specific data changing.
DIP in frontend is violated when React hooks import concrete domain classes instead of depending on interfaces — coupling the component layer to the domain implementation.
import { CanvasDocument } from '../domain/canvas/CanvasDocument';
function useCanvasDocument() {
const [doc] = useState(() => new CanvasDocument());
return doc;
}
The fix: hooks receive the domain object via props/context — they do not construct it:
import type { ICanvasDocument } from '../domain/canvas/ICanvasDocument';
export interface ICanvasDocument {
subscribe(listener: () => void): () => void;
getSnapshot(): CanvasSnapshot;
addShape(shape: IShape): void;
selectShape(id: ShapeId): void;
}
function useCanvasDocument(doc: ICanvasDocument): CanvasSnapshot {
return useSyncExternalStore(
(notify) => doc.subscribe(notify),
() => doc.getSnapshot(),
);
}
function Canvas({ doc }: { doc: ICanvasDocument }) {
const snapshot = useCanvasDocument(doc);
return <svg>{/* ... */}</svg>;
}
const mockDoc: ICanvasDocument = {
subscribe: (fn) => { return () => {}; },
getSnapshot: () => ({ shapes: [], selectedIds: new Set(), }),
addShape: jest.fn(),
selectShape: jest.fn(),
};
render(<Canvas doc={mockDoc} />);
The Canvas component tests now require only a plain object satisfying ICanvasDocument — not a full CanvasDocument instance with all its dependencies.
| Principle |
Frontend Manifestation |
Resolution |
| SRP |
God component: renders + manages tools + handles shortcuts |
Split into Canvas, Toolbar, LayerPanel, ZoomControls — each one reason to change |
| SRP |
Hook that fetches + transforms + formats errors |
Separate useShapeApi, parseApiShape, useShape |
| OCP |
ShapeRenderer switch opened for every new shape type |
Renderer registry: registerRenderer('path', PathRenderer) — zero existing changes |
| LSP |
EyedropperTool.onPointerMove silent no-op violates preview contract |
toolContract shared test suite catches behavioral violations |
| ISP |
All components receive full CanvasSnapshot — unnecessary re-renders |
Segregated snapshots: ToolbarSnapshot, CanvasViewSnapshot, PropertiesPanelSnapshot |
| DIP |
Hook constructs new CanvasDocument() — coupled to concrete class |
Hook receives ICanvasDocument interface — testing requires only a plain object stub |
Part 9 implements the SelectionState finite state machine and the Observer Pattern — decoupling the toolbar, properties panel, and canvas from each other so that selection changes propagate without direct component coupling or shared context.