Composite Pattern in Go: Recursive Components

The Composite pattern solves a specific problem: you have objects that form tree structures, and you want to treat individual items and groups of items the same way. Think file systems where both...

Key Insights

  • The Composite pattern lets you treat individual objects and groups of objects uniformly, enabling recursive operations that naturally traverse tree structures without conditional type checking.
  • Go’s implicit interface satisfaction makes the Composite pattern particularly clean—structs automatically implement interfaces when they have the right methods, eliminating boilerplate.
  • The pattern shines for hierarchical data like file systems, UI trees, and organization charts, but adds complexity when your hierarchy is shallow or operations differ significantly between leaves and composites.

Introduction to the Composite Pattern

The Composite pattern solves a specific problem: you have objects that form tree structures, and you want to treat individual items and groups of items the same way. Think file systems where both files and folders respond to “get size,” or UI frameworks where buttons and panels both need to render.

Without this pattern, client code drowns in type switches and conditional logic. With it, you call the same method on any node, and the structure handles the recursion internally.

The pattern has three participants: a Component interface that defines common operations, Leaf types that implement the interface for individual objects, and Composite types that hold children and delegate operations to them. The composite’s implementation typically iterates over its children, calling the same method recursively.

Go’s structural typing makes this pattern feel natural. You don’t declare that a type implements an interface—it just does if the methods match. This reduces ceremony and keeps the focus on behavior.

Core Components and Go Interfaces

Let’s start with the foundational structure. The Component interface defines what every node in our tree can do:

package composite

type Component interface {
    Operation() string
    Add(child Component)
    Remove(child Component)
    GetChildren() []Component
    IsComposite() bool
}

The IsComposite() method helps when you need to distinguish types, though idiomatic Go often uses type assertions instead. The Add() and Remove() methods present a design decision: should leaves implement them as no-ops, or should they panic?

Here’s a base implementation for leaves that provides sensible defaults:

type BaseLeaf struct {
    name string
}

func (b *BaseLeaf) Add(child Component)       {}
func (b *BaseLeaf) Remove(child Component)    {}
func (b *BaseLeaf) GetChildren() []Component  { return nil }
func (b *BaseLeaf) IsComposite() bool         { return false }

func (b *BaseLeaf) Operation() string {
    return b.name
}

And the composite that actually manages children:

type BaseComposite struct {
    name     string
    children []Component
}

func (c *BaseComposite) Add(child Component) {
    c.children = append(c.children, child)
}

func (c *BaseComposite) Remove(child Component) {
    for i, ch := range c.children {
        if ch == child {
            c.children = append(c.children[:i], c.children[i+1:]...)
            return
        }
    }
}

func (c *BaseComposite) GetChildren() []Component {
    return c.children
}

func (c *BaseComposite) IsComposite() bool {
    return true
}

func (c *BaseComposite) Operation() string {
    result := c.name + "("
    for i, child := range c.children {
        if i > 0 {
            result += ", "
        }
        result += child.Operation()
    }
    return result + ")"
}

Building a File System Example

The file system metaphor makes the Composite pattern concrete. Files are leaves with fixed sizes. Directories are composites whose size equals the sum of their contents.

type FileSystemNode interface {
    Size() int64
    Print(indent int)
    Name() string
}

type File struct {
    name string
    size int64
}

func NewFile(name string, size int64) *File {
    return &File{name: name, size: size}
}

func (f *File) Size() int64 {
    return f.size
}

func (f *File) Name() string {
    return f.name
}

func (f *File) Print(indent int) {
    fmt.Printf("%s- %s (%d bytes)\n", strings.Repeat("  ", indent), f.name, f.size)
}

The directory implementation holds children and delegates:

type Directory struct {
    name     string
    children []FileSystemNode
}

func NewDirectory(name string) *Directory {
    return &Directory{name: name, children: make([]FileSystemNode, 0)}
}

func (d *Directory) Add(node FileSystemNode) {
    d.children = append(d.children, node)
}

func (d *Directory) Size() int64 {
    var total int64
    for _, child := range d.children {
        total += child.Size()
    }
    return total
}

func (d *Directory) Name() string {
    return d.name
}

func (d *Directory) Print(indent int) {
    fmt.Printf("%s+ %s/ (%d bytes total)\n", 
        strings.Repeat("  ", indent), d.name, d.Size())
    for _, child := range d.children {
        child.Print(indent + 1)
    }
}

Usage becomes straightforward:

func main() {
    root := NewDirectory("project")
    src := NewDirectory("src")
    src.Add(NewFile("main.go", 1024))
    src.Add(NewFile("utils.go", 512))
    
    docs := NewDirectory("docs")
    docs.Add(NewFile("README.md", 256))
    
    root.Add(src)
    root.Add(docs)
    root.Add(NewFile("go.mod", 128))
    
    root.Print(0)
    fmt.Printf("\nTotal size: %d bytes\n", root.Size())
}

Recursive Operations and Traversal

The Size() method demonstrates depth-first traversal implicitly. Each directory asks its children for their sizes, which triggers the same question down the tree. The recursion unwinds naturally.

For more complex traversals, you might want explicit control:

func (d *Directory) Walk(fn func(FileSystemNode, int)) {
    d.walkRecursive(fn, 0)
}

func (d *Directory) walkRecursive(fn func(FileSystemNode, int), depth int) {
    fn(d, depth)
    for _, child := range d.children {
        if dir, ok := child.(*Directory); ok {
            dir.walkRecursive(fn, depth+1)
        } else {
            fn(child, depth+1)
        }
    }
}

func (d *Directory) Find(predicate func(FileSystemNode) bool) []FileSystemNode {
    var results []FileSystemNode
    d.Walk(func(node FileSystemNode, _ int) {
        if predicate(node) {
            results = append(results, node)
        }
    })
    return results
}

This lets you search the entire tree:

goFiles := root.Find(func(node FileSystemNode) bool {
    return strings.HasSuffix(node.Name(), ".go")
})

Handling Parent References and Cycles

Bidirectional navigation requires parent references, but these create opportunities for cycles. Here’s a safer approach:

type SafeDirectory struct {
    name     string
    parent   *SafeDirectory
    children []FileSystemNode
}

func (d *SafeDirectory) SetParent(parent *SafeDirectory) error {
    if d.wouldCreateCycle(parent) {
        return errors.New("operation would create cycle")
    }
    d.parent = parent
    return nil
}

func (d *SafeDirectory) wouldCreateCycle(newParent *SafeDirectory) bool {
    current := newParent
    for current != nil {
        if current == d {
            return true
        }
        current = current.parent
    }
    return false
}

func (d *SafeDirectory) Add(node FileSystemNode) error {
    if dir, ok := node.(*SafeDirectory); ok {
        if err := dir.SetParent(d); err != nil {
            return err
        }
    }
    d.children = append(d.children, node)
    return nil
}

func (d *SafeDirectory) Path() string {
    if d.parent == nil {
        return d.name
    }
    return d.parent.Path() + "/" + d.name
}

Real-World Application: UI Component Tree

UI frameworks use this pattern extensively. Here’s a simplified widget system:

type Bounds struct {
    X, Y, Width, Height int
}

type Widget interface {
    Render(canvas *Canvas)
    CalculateBounds() Bounds
    SetPosition(x, y int)
}

type Button struct {
    x, y   int
    width  int
    height int
    label  string
}

func (b *Button) Render(canvas *Canvas) {
    canvas.DrawRect(b.x, b.y, b.width, b.height)
    canvas.DrawText(b.x+5, b.y+5, b.label)
}

func (b *Button) CalculateBounds() Bounds {
    return Bounds{b.x, b.y, b.width, b.height}
}

func (b *Button) SetPosition(x, y int) {
    b.x, b.y = x, y
}

type Container struct {
    x, y     int
    padding  int
    children []Widget
}

func (c *Container) Add(w Widget) {
    c.children = append(c.children, w)
}

func (c *Container) Render(canvas *Canvas) {
    for _, child := range c.children {
        child.Render(canvas)
    }
}

func (c *Container) CalculateBounds() Bounds {
    if len(c.children) == 0 {
        return Bounds{c.x, c.y, 0, 0}
    }
    
    minX, minY := c.x, c.y
    maxX, maxY := c.x, c.y
    
    for _, child := range c.children {
        b := child.CalculateBounds()
        if b.X < minX { minX = b.X }
        if b.Y < minY { minY = b.Y }
        if b.X+b.Width > maxX { maxX = b.X + b.Width }
        if b.Y+b.Height > maxY { maxY = b.Y + b.Height }
    }
    
    return Bounds{
        minX - c.padding, 
        minY - c.padding,
        maxX - minX + 2*c.padding,
        maxY - minY + 2*c.padding,
    }
}

func (c *Container) SetPosition(x, y int) {
    dx, dy := x-c.x, y-c.y
    c.x, c.y = x, y
    for _, child := range c.children {
        b := child.CalculateBounds()
        child.SetPosition(b.X+dx, b.Y+dy)
    }
}

Trade-offs and Go-Specific Considerations

The Composite pattern isn’t free. Consider these trade-offs:

Type safety erosion: The interface includes methods that don’t make sense for all implementations. Calling Add() on a file is meaningless. You can panic, return an error, or silently ignore—none are great.

Overly general interfaces: To treat everything uniformly, you might end up with a bloated interface. Consider whether you actually need uniform treatment or if explicit type handling would be clearer.

When to avoid it: If your hierarchy is only two levels deep, or if leaf and composite operations differ significantly, the pattern adds complexity without benefit. A simple slice of items might suffice.

Alternative: Visitor pattern: When you need many operations over a stable structure, Visitor separates operations from the hierarchy. Go’s lack of method overloading makes Visitor more verbose, but it keeps the component interface focused.

Go-specific wins: Embedding lets composites share behavior. You can embed a BaseComposite struct and only override what differs. Interface composition lets you define focused interfaces that combine into larger ones.

The Composite pattern works best when you genuinely need recursive structures with uniform operations. File systems, organization charts, expression trees, and UI component hierarchies are natural fits. For simpler cases, don’t reach for patterns—a well-named slice does the job with less abstraction.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.