← Design patterns
Composition Parts, groups, and shared operations

Composite

One element. A whole group. The same request.

You have arranged a photo, a heading, and a small badge into a card. The spacing finally looks right. Now the whole card needs to move twenty units to the right.

A design editor lets you group those elements and move them together. Put a group inside another group, and the same action still works. The interesting decision is who follows that nesting.

TypeScriptGoPythonOne move/render contract · across the comparison

01 / Let a group be a usable thing

The move tool can keep one kind of request.

For a flat canvas, a list of elements and a loop are sensible. The tool applies a delta to each selected element. Then users want a heading and badge to stay together, and that small group needs to live inside a larger card.

You could teach every tool to distinguish an element from a group, inspect its children, and keep walking. Soon moving, rendering, and other operations each carry their own version of the nesting rule.

Composite represents individual parts and groups through a common interface, so a caller can apply the same operation to either. An element handles its own work. A group asks its children to perform that work, and a child can itself be another group.

Here, that interface is Graphic. Both Element and Group can move and render. The move tool accepts a Graphic and issues one request. It does not need a separate branch for a whole card.

02 / Follow the same operation downward

The card contains a group that contains two elements.

Our canvas has two immediate children: a card and a separate caption. The card contains a photo and a details group. Details contains the heading and badge. Selecting the card includes three leaf elements; the caption remains outside it.

TOOL → GRAPHIC

One request

The move tool supplies a delta. It accepts either a leaf or a group.

GROUP → CHILD GRAPHICS

Delegate in order

The card asks its photo and details group to move. Details asks its heading and badge.

ELEMENT → DRAWING RECORD

Do the leaf work

Each element updates its position in a new value and renders one record.

Moving the card by (20, 10) reaches the photo once, the heading once, and the badge once. Groups do not add a second visible object to the rendered artwork. Their render operation combines the records returned by their children, preserving order.

One card move, shared canvas coordinates
ElementBeforeAfterReached through
Photo40, 6060, 70card → photo
Heading230, 65250, 75card → details → heading
Badge250, 140270, 150card → details → badge
Caption40, 27040, 270Outside the selected card

The group owns the recursive step. If the tool also walks every descendant and moves it separately, the photo gets the delta twice. The heading and badge receive it three times: through the card, through details, and through their own extra calls.

An empty group is useful to think about too. It accepts a move and has no child work to perform. Rendering it returns an empty list. The caller can keep the same operation without inventing a special “empty graphic” exception.

03 / Read the contract

A leaf does the work. A group combines it.

The basic form shows a leaf implementing Graphic. The practical form adds Group with children typed as that same interface. Follow the method call inside its loop: each child decides how to carry out the operation.

These examples return a new moved subtree. The document owner must adopt it; ignoring the return value leaves the current artwork unchanged. Rendering produces independent drawing records. The browser turns those records into schematic SVG shapes, while the standalone programs print their coordinates.

All positions are in shared canvas space, and a group has no position or transform of its own.

Graphic is the common operation contract. An Element moves by returning a new element and renders one drawing record. The trace makes each method call visible. Supporting Mark fields are in the complete file.

TypeScriptReading
scene.ts
export interface Graphic {
	readonly id: string;
	moveBy(dx: number, dy: number, trace: string[]): Graphic;
	render(): Mark[];
}
export class Element implements Graphic {
	readonly id: string;
	readonly #mark: Mark;
	constructor(mark: Mark) {
		this.id = mark.id;
		this.#mark = Object.freeze({ ...mark });
		Object.freeze(this);
	}
	moveBy(dx: number, dy: number, trace: string[]): Element {
		trace.push(this.id);
		return new Element({ ...this.#mark, x: this.#mark.x + dx, y: this.#mark.y + dy });
	}
	render(): Mark[] {
		return [{ ...this.#mark }];
	}
}
GoAlongside
scene.go
type Graphic interface {
	ID() string
	MoveBy(dx, dy int, trace *[]string) Graphic
	Render() []Mark
}
type Element struct{ mark Mark }

func NewElement(mark Mark) Element { return Element{mark} }
func (e Element) ID() string       { return e.mark.ID }
func (e Element) MoveBy(dx, dy int, trace *[]string) Graphic {
	*trace = append(*trace, e.ID())
	mark := e.mark
	mark.X += dx
	mark.Y += dy
	return NewElement(mark)
}
func (e Element) Render() []Mark { return []Mark{e.mark} }
Reading the TypeScript

Graphic names the operations both classes satisfy. A Group stores Graphic children, so the call to child.moveBy works for either implementation. The move tool does not inspect their concrete classes.

map builds the moved children in order; flatMap joins their drawing records. The constructors copy and freeze the scalar record or child array. The provided node classes expose no mutation methods, so unchanged children can be retained safely in another version.

readonly alone is a type-system restriction. The copying and freezing are deliberate choices in this implementation, and every future Graphic implementation must honor the same contract. Sharing an arbitrary mutable child would change the ownership story.

Reading the Go

Go’s interfaces are satisfied by matching methods. Element and Group both satisfy Graphic, and []Graphic can hold them together. Value receivers keep the methods on the provided types from replacing their receiver’s state.

A slice still refers to backing storage, so the Group constructor copies its input slice. Rendering creates new slices of value records, and moving constructs a new Group. The fields are unexported; extracting these types into a package would enforce that boundary against external callers.

The trace is passed as *[]string so each visited node can append to the same observation. It records traversal; it does not decide what moves.

Reading the PythonProtocols, dataclasses, and tuples

The Graphic Protocol gives leaves and groups one move/render contract. Frozen dataclasses hold marks and leaves; Group copies its iterable into a tuple before delegating to each child in order.

A move returns a new subtree and appends every visited ID to the shared trace. Render returns fresh mark lists, so the caller can inspect output without changing the artwork.

04 / Predict, change, observe

One call should reach three elements.

The card starts selected. Predict three leaf move calls, then move it. Compare the artwork, the coordinates, and the recorded method calls. The caption should stay in place.

Reset and switch to the broken dispatch. The tool now calls the card and separately calls its descendants. Predict again: the photo receives two moves; the heading and badge receive three each. The grouping itself is unchanged—the caller repeated work already owned by the groups.

DESIGN EDITOR / ONE OPERATION, NESTED REACH

Move the card. Follow its children.

Choose layers in the tree, predict the leaf calls, then move. The artwork is drawn from the same TypeScript records returned by the examples.

Layers

Indentation means “child of.” Select a group to include its descendants.

Selected: card

Artwork

Groups give these elements a shared handle.

Artwork rendered from leaf recordsFIELD NOTESNEWA place to begin
Dashed outlines are editor guides, not extra drawing records. Faint rectangles show positions before the last move. The view expands to keep the artwork visible.

Grouping requires adjacent siblings. Ungrouping takes one group. The canvas stays as the root. If both an ancestor and its child are selected for a move, the tool starts at the ancestor.

Tool calls0
Node visits0
Leaf move calls0
Leaf records in paint order · shared canvas coordinates
ElementBeforeNowMove calls
photo40, 6040, 600
heading230, 65230, 650
badge250, 140250, 1400
caption40, 27040, 2700
Trace the move calls

No move calls recorded. Grouping changes the structure without calling moveBy.

For a structural experiment, choose the flat layout and clear the selection. Select heading and badge, then group them. Select that new group and photo, and group again. You have built a nested card without changing a single coordinate.

Ungroup one of those groups. Its immediate children stay where they are and become selectable at the parent level. Now select a different part and predict how far the next operation reaches.

The lab calls the same TypeScript move/render methods shown above, with separate editor code for selection and grouping. The native implementations run the same move/render cases as standalone programs.

05 / Make a decision

Keep the operation and the structure distinct.

Review who should recurse, what an ungroup action should preserve, and which capabilities belong on the common interface.

The card contains a photo and a details group. Both implement moveBy. What should the move tool do after calling card.moveBy?

06 / Give it a real owner

The editor owns membership. A tool asks for behavior.

Picture a user pressing an arrow key in a slide editor. The editor resolves the selection, removes descendants already covered by a selected ancestor, and sends one delta to each remaining root. It replaces those subtrees with their returned values, then asks the canvas to render.

The move tool should not rebuild the membership rule. The editor also owns grouping, ungrouping, IDs, and selection changes. It can inspect groups for that structural work while ordinary move/render callers keep the small Graphic contract.

Our grouping helper accepts adjacent siblings and replaces that contiguous run with one group. This preserves drawing order: elements that used to paint between two selected items cannot silently jump above or below them. The new group uses their existing canvas coordinates. Ungrouping replaces only that group with its immediate children.

Imagine adding a sticker element. It supplies the common move and render behavior; existing groups can contain it without changing their delegation. If stickers need a distinct record kind, the downstream drawing code still needs to understand it.

The boundaries a larger editor would need

Coordinate systems. This example moves leaf coordinates directly. A richer editor may retain transforms on groups and combine parent and child transforms when drawing. Reparenting then needs to preserve the intended world-space appearance. Applying both a parent transform and the same delta to each leaf would mix two models.

A tree of placements. These scenes have one parent per placement and unique IDs. The lab’s edits preserve that structure. The generic constructors do not validate arbitrary imported graphs. Reusing one placement under two parents can cause repeated work; a cycle prevents recursive traversal from finishing. Validate imported structure and place shared image assets behind separate placement nodes.

Immutable versions and cost. A move allocates a new visited subtree and leaves the previous one usable, an ownership policy this example chose. Rendering also creates intermediate lists at groups; very deep nesting can repeat copying. A shared output buffer, local transforms, or carefully managed mutable nodes may suit another workload. Measure the actual editor before choosing.

Limits and failure. The source expects trusted integer geometry with sums in its documented range. The lab validates deltas and rejects any candidate that would put a leaf beyond its coordinate limit, retaining the previous root. Imported designs need their own size, depth, and numeric checks.

Other operations need honest semantics. A group’s selection bounds can come from its children, but cached bounds need invalidation. Visibility, opacity, hit testing, clipping, and resource disposal each need a clear combination rule. Sharing one interface is useful only when each implementation can honor the operation’s promise.

Build UIs?Every appendChild you call goes through an interface a text node shares with an element, and a settings page with section checkboxes makes that choice yours.

Where it already is in your components

If you reach for firstElementChild instead of firstChild, you already follow a rule that comes from this pattern. The DOM is a Composite the browser runs: an element and the text inside it are both a Node, and the Node interface gives every node childNodes, textContent, and appendChild. An element answers textContent by combining its children, the way a group combines drawing records: it returns “the concatenation of the data of all the Text node descendants” in tree order, and an empty element returns an empty string.

The DOM made the other choice from the one section 08 weighs: child operations sit on the shared interface, so a leaf has to refuse them. The whitespace between a <ul> and its first <li> is a text node, and MDN puts it plainly: “Any whitespace will create a #text node.” So list.firstChild?.appendChild(item) type-checks, because TypeScript’s DOM types give that child appendChild too, and then Chromium throws a HierarchyRequestError: “Failed to execute 'appendChild' on 'Node': This node type does not support this method.” Asking for firstElementChild skips the text leaves.

The same insertion checks keep the DOM a tree, which this lesson’s editor has to guarantee for itself. Append an element that already has a parent, and the browser removes it from that parent first, so one element never sits in two lists. Append an element into its own descendant, and Chromium throws “The new child element contains the parent.” An SVG <g> is the geometric version of the idea: MDN notes that “Transformations applied to the <g> element are performed on its child elements,” the group-transform model the boundaries above describe.

When you have to own it

Now your app has a notification settings page. Email holds a Billing section, with invoices and payment failures, and a Product section; push notifications sit outside it. Every section has its own checkbox: checked when everything under it is on, a dash when only some of it is, and a click turns everything under it on or off. MDN names this the most common use of a checkbox’s indeterminate state: “If any one or more of the sub-options have a different state than the others, the owning checkbox is in the indeterminate state.”

The decision is where a section’s state lives. Store a checked flag on each section, and every toggle has to walk up and correct its ancestors, or an effect has to keep them in step. Give toggles and sections one interface instead. Each reports how many of its settings are on, out of how many, and each accepts a request to set an id. A section adds up its children and passes the request down, so a toggle three levels deep needs no code that knows about sections.

settings-tree.ts
export type Tally = { on: number; total: number };

// A single toggle and a whole section answer the same two requests.
export interface Setting {
	readonly id: string;
	tally(): Tally;
	set(id: string, on: boolean): Setting;
}

export class Toggle implements Setting {
	readonly id: string;
	readonly on: boolean;
	constructor(id: string, on: boolean) {
		this.id = id;
		this.on = on;
	}
	tally(): Tally {
		return { on: this.on ? 1 : 0, total: 1 };
	}
	set(id: string, on: boolean): Toggle {
		return id === this.id ? new Toggle(this.id, on) : this;
	}
}

export class Section implements Setting {
	readonly id: string;
	readonly children: readonly Setting[];
	constructor(id: string, children: readonly Setting[]) {
		this.id = id;
		this.children = Object.freeze([...children]);
	}
	tally(): Tally {
		let on = 0;
		let total = 0;
		for (const child of this.children) {
			const part = child.tally();
			on += part.on;
			total += part.total;
		}
		return { on, total };
	}
	set(id: string, on: boolean): Section {
		// Setting this section sends each child the same request under its own id.
		const all = id === this.id;
		return new Section(
			this.id,
			this.children.map((child) => child.set(all ? child.id : id, on))
		);
	}
}

// Derived on every render, never stored, so a section cannot disagree with its children.
export function checkbox({ on, total }: Tally): { checked: boolean; indeterminate: boolean } {
	return { checked: total > 0 && on === total, indeterminate: on > 0 && on < total };
}

The checkbox is derived from that tally on every render, so it cannot disagree with its children. Decide what an empty section means, as this lesson did for an empty group: here it counts nothing, so an empty Beta section never turns its parent into a dash. A recursive component draws the rows, but it only reads tally() and sends set; the settings tree is the Composite.

Set indeterminate as a property: MDN notes “it cannot be set using an HTML attribute.” In Svelte 5.57, indeterminate={state.indeterminate} on the input set the property. In React 19.1.0 the same prop left it unset and logged “Received true for a non-boolean attribute indeterminate,” so set it in a ref callback. In Chromium, clicking a section that showed a dash checked it and turned on every setting under it, and the accessibility tree reported the dash as “mixed.”

07 / Recognize the relationship

Grouping is already a public feature of drawing systems.

The useful connection is a compound item that remains usable as an item. Different libraries choose different geometry and ownership models around that relationship.

Qt has a group that is also a graphics item.

QGraphicsItemGroup inherits from QGraphicsItem and treats its children together as one item. Qt documents that adding or removing items through its grouping methods preserves their scene-relative position and transformation. That is the same user expectation behind our “ungroup without a jump” exercise, implemented with a richer graphics model.

Read the QGraphicsItemGroup contract ↗

Three.js gives grouped objects a shared handle.

Three.js documents Group as a clearer way to work with groups of Object3D objects. Its example adds two meshes and manipulates them as a group. Object3D exposes children and distinguishes local and world transforms.

Read about Group ↗ Read the Object3D contract ↗

08 / Make the call

Find an operation that makes sense for both.

Composite fits when parts and nested groups need a meaningful shared operation: render a graphic, total a bundle, or evaluate a group of conditions. The group’s combination rule must be clear. Drawing preserves child order; a total adds contributions; a condition group might combine results with AND or OR.

Keep a flat list when the problem is flat. If the variants are fixed and different operations need to inspect their structure, an explicit recursive data type with separate functions can be clearer. A tree data structure supplies nesting; Composite adds a useful uniform behavior for its callers.

Choose the responsibility you need
NeedUseful starting point
Operate on a part or a nested whole through one contractComposite, with a defined delegation or combination rule.
Apply an operation to unrelated flat itemsA list and a loop.
Add behavior around a compatible subjectDecorator, whose wrapper has a different responsibility.
Control access to a represented resourceProxy.
Share image or style data among placementsShared immutable assets or Flyweight, alongside the scene tree.

Uniformity does not mean every node must support adding children. A photo can honestly move and render; it cannot honestly contain another photo in this model. The textbook form sometimes declares add and remove on the shared interface so a client never has to ask which kind of node it holds; the price is that leaves must reject those calls. We keep child operations on the group, trading that transparency for a contract every node can honor.

09 / Take the idea with you

Explain how one request reaches the whole card.

Try explaining it without the pattern name: the tool asks one graphic to move. An element changes its own position in a new value. A group asks each child to do the same, and the owner adopts the result.

Then change the design. Move the badge outside details but leave it inside the card. Which selection will still move the badge? Which will stop reaching it? You can answer by following membership, without changing the move tool.

Connections to follow nextRelated lessons

Decorator also uses compatible interfaces, with behavior wrapped around a subject. Flyweight can share the assets behind separate placements. Visitor keeps an operation outside the tree, so a new behavior can be added without editing every node type. Interpreter uses the same tree shape to give an expression its meaning, where each node evaluates rather than moves. Ownership, aliasing, and lifetimes helps explain who retains scene versions and what may safely be shared.