Composite Design Pattern in Go
Think about a deployment pipeline. Some steps are simple – run a migration, restart a service, send a notification. Other steps are groups of steps – “deploy backend” means run migrations, build the binary, restart the service, and run smoke tests. And groups can contain other groups – “deploy everything” contains “deploy backend,” “deploy frontend,” and “update DNS.”
The client that triggers the pipeline shouldn’t care whether it’s executing a single step or a deeply nested group of steps. It calls Execute() on whatever it has, and the right thing happens. A single step runs itself. A group runs its children. A group containing groups recurses naturally. The interface is the same at every level.
The Composite pattern makes this possible. It lets you treat individual objects and groups of objects through the same interface. The tree structure – steps containing steps containing steps – is handled uniformly. No type switches, no “is this a leaf or a container” checks, no special-casing. One interface, recursive composition, and the client stays blissfully unaware of the depth.
Go’s interfaces and struct composition make this pattern clean. In this article, we’ll build a deployment pipeline where tasks and task groups share the same interface, compose recursively, and execute uniformly.
What Is the Composite Pattern?
“Compose objects into tree structures to represent part-whole hierarchies. Composite lets clients treat individual objects and compositions of objects uniformly.” – Gang of Four
The intent: let clients work with single objects and groups of objects through the same interface, hiding the structural complexity. A leaf does its own work. A composite delegates to its children. The client calls the same method on both and doesn’t care which one it’s talking to.
Without this pattern, tree-structured execution requires type checks everywhere:
func execute(step interface{}) {
switch s := step.(type) {
case *SingleTask:
s.Run()
case *TaskGroup:
for _, child := range s.Tasks {
execute(child)
}
}
}
Every function that interacts with the pipeline needs this switch. Add a new type (conditional task, parallel group) and every switch needs updating. The client must always know what it’s dealing with.
With the Composite pattern, the client just calls:
step.Execute()
Whether step is a single task or a group of 50 nested tasks, it works. The interface hides the structure.
Core Components
Three participants.
Component – the interface shared by both individual objects and groups.
type Task interface {
Execute() error
Name() string
}
Leaf – a single object that does actual work. It implements the interface directly, with no children.
Composite – a group that holds a collection of Components (which can be leaves or other composites). It implements the interface by delegating to its children.
The structure:
Task (interface)
├── SingleTask (leaf) -- does work
└── TaskGroup (composite) -- delegates to children
├── SingleTask
├── SingleTask
└── TaskGroup (nested composite)
├── SingleTask
└── SingleTask
The client operates on Task. It never knows whether it holds a leaf or a tree.
Code Walkthrough: A Deployment Pipeline
We’re building a deployment pipeline where individual tasks and groups of tasks share the same interface. Groups can contain other groups, forming an arbitrarily deep tree.
The Component Interface
type Task interface {
Execute() error
Name() string
}
Two methods. Every pipeline step – whether it’s “restart nginx” or “deploy entire production stack” – satisfies this.
The Leaf: SingleTask
A single unit of work:
type SingleTask struct {
name string
action func() error
}
func NewTask(name string, action func() error) *SingleTask {
return &SingleTask{name: name, action: action}
}
func (t *SingleTask) Execute() error {
fmt.Printf(" [task] %s\n", t.name)
return t.action()
}
func (t *SingleTask) Name() string {
return t.name
}
Simple: it has a name and an action. Execute() runs the action. No children, no delegation.
The Composite: TaskGroup
A group of tasks that executes its children sequentially:
type TaskGroup struct {
name string
children []Task
}
func NewTaskGroup(name string) *TaskGroup {
return &TaskGroup{name: name}
}
func (g *TaskGroup) Add(tasks ...Task) *TaskGroup {
g.children = append(g.children, tasks...)
return g
}
func (g *TaskGroup) Execute() error {
fmt.Printf("[group] %s (%d steps)\n", g.name, len(g.children))
for _, child := range g.children {
if err := child.Execute(); err != nil {
return fmt.Errorf("%s: %w", g.name, err)
}
}
return nil
}
func (g *TaskGroup) Name() string {
return g.name
}
TaskGroup implements Task – same interface as SingleTask. But internally it holds a slice of Task and iterates over them. Children can be single tasks or other groups. The recursion is structural: a group’s Execute calls each child’s Execute, which might itself be a group.
Add returns *TaskGroup for fluent construction, but the group is used as a Task once composed.
Building the Pipeline
func main() {
migrate := NewTask("run migrations", func() error {
fmt.Println(" applying schema changes...")
return nil
})
build := NewTask("build binary", func() error {
fmt.Println(" compiling go build...")
return nil
})
restart := NewTask("restart service", func() error {
fmt.Println(" systemctl restart api...")
return nil
})
smokeTest := NewTask("smoke test", func() error {
fmt.Println(" curl health endpoint...")
return nil
})
deployBackend := NewTaskGroup("deploy backend").Add(
migrate, build, restart, smokeTest,
)
updateDNS := NewTask("update DNS", func() error {
fmt.Println(" pointing A record...")
return nil
})
notifyTeam := NewTask("notify team", func() error {
fmt.Println(" posting to #deploys...")
return nil
})
deployAll := NewTaskGroup("deploy everything").Add(
deployBackend, updateDNS, notifyTeam,
)
if err := deployAll.Execute(); err != nil {
fmt.Println("FAILED:", err)
}
}
[group] deploy everything (3 steps)
[group] deploy backend (4 steps)
[task] run migrations
applying schema changes...
[task] build binary
compiling go build...
[task] restart service
systemctl restart api...
[task] smoke test
curl health endpoint...
[task] update DNS
pointing A record...
[task] notify team
posting to #deploys...
deployAll is a group containing a group and two single tasks. The client calls deployAll.Execute() – one line – and the entire tree executes recursively. The nested group (deployBackend) runs its four children before the pipeline continues with updateDNS and notifyTeam.
Error Handling: Stopping on Failure
If the build step fails:
build := NewTask("build binary", func() error {
return fmt.Errorf("compilation error: missing import")
})
[group] deploy everything (3 steps)
[group] deploy backend (4 steps)
[task] run migrations
applying schema changes...
[task] build binary
FAILED: deploy everything: deploy backend: compilation error: missing import
The error bubbles up through the tree. TaskGroup.Execute() wraps each error with the group’s name, creating a clear path: deploy everything: deploy backend: compilation error. The pipeline stops at the first failure – no subsequent steps execute.
The Visualization
deployAll (TaskGroup)
├── deployBackend (TaskGroup)
│ ├── migrate (SingleTask)
│ ├── build (SingleTask)
│ ├── restart (SingleTask)
│ └── smokeTest (SingleTask)
├── updateDNS (SingleTask)
└── notifyTeam (SingleTask)
The client calls Execute() on the root. The tree handles itself.
Adding Parallel Execution
A natural extension: some groups should run their children concurrently instead of sequentially.
type ParallelGroup struct {
name string
children []Task
}
func NewParallelGroup(name string) *ParallelGroup {
return &ParallelGroup{name: name}
}
func (g *ParallelGroup) Add(tasks ...Task) *ParallelGroup {
g.children = append(g.children, tasks...)
return g
}
func (g *ParallelGroup) Execute() error {
fmt.Printf("[parallel] %s (%d steps)\n", g.name, len(g.children))
errs := make(chan error, len(g.children))
for _, child := range g.children {
go func(t Task) {
errs <- t.Execute()
}(child)
}
for range g.children {
if err := <-errs; err != nil {
return fmt.Errorf("%s: %w", g.name, err)
}
}
return nil
}
func (g *ParallelGroup) Name() string {
return g.name
}
ParallelGroup satisfies Task. It looks identical to TaskGroup from the outside. But internally it launches goroutines. You can mix sequential and parallel groups freely:
deployAll := NewTaskGroup("deploy everything").Add(
NewParallelGroup("deploy services").Add(backendDeploy, frontendDeploy),
updateDNS,
notifyTeam,
)
Backend and frontend deploy concurrently. DNS update waits for both to finish. The tree structure composes naturally.
When to Use It
The Composite pattern fits when you have a tree structure where individual items and groups should be treated uniformly:
- Pipeline/workflow systems where steps can be grouped, nested, and executed through a single
Execute()call regardless of depth. - File system operations where files and directories share operations (size, permissions, search) and directories recurse into their contents.
- UI component trees where containers hold widgets, widgets can be containers themselves, and rendering/layout applies uniformly.
- Permission systems where individual permissions and permission groups are evaluated through the same
HasAccess()interface. - Pricing/discount engines where individual line items and bundles of items both support
Total()and compose into arbitrary nesting.
Skip it when:
- The structure is flat. If you’ll never have groups within groups – just a list of items – a simple slice is clearer than a composite tree.
- Leaves and composites have genuinely different operations. If groups need
Add/Removebut leaves don’t, forcing both into one interface creates methods that don’t make sense on leaves. Consider separating the interfaces. - You need random access by index. Composite trees are traversed, not indexed. If clients need
GetChild(3)orFindByName("step-7"), the uniform interface doesn’t help – you need tree-specific navigation.
Pros and Cons
Pros:
- Uniform client code – the client calls
Execute()on a leaf or a tree of 1000 nodes. Same code, same interface. No type switches, no depth checks. - Natural recursion – the composite’s
Execute()calls each child’sExecute(), which might recursively call further children. The tree structure handles itself. - Open/Closed for new node types – add
ParallelGroup,ConditionalTask,RetryTask– each implementsTaskand slots into existing trees without modifying existing code. - Flexible composition at runtime – build pipelines dynamically based on config, feature flags, or user input. The tree structure is data, not hardcoded control flow.
Cons:
- Overly general interface – the shared interface must make sense for both leaves and composites. Methods like
Add()on a leaf are meaningless. Go doesn’t force you to include them (unlike some OOP languages), but the design tension is real. - Hard to constrain – it’s difficult to restrict what types a composite can contain. “A deploy group should only contain deploy tasks, not notification tasks” – the type system doesn’t enforce this when everything is just
Task. - Debugging nested trees – when a deeply nested step fails, understanding which level and which branch requires clear error wrapping or logging at every level.
Best Practices
- Wrap errors with the group name at each level.
fmt.Errorf("%s: %w", g.name, err)creates a breadcrumb trail:deploy everything: deploy backend: build binary: compilation error. Without this, a failure deep in the tree produces an error with no context about where in the tree it happened. - Keep the Component interface minimal. Only include methods that genuinely apply to both leaves and composites.
Execute()andName()work for both.Add()is specific to composites and should stay onTaskGroup, not onTask. - Return
*TaskGroupfromAdd()for fluent construction, but useTaskfor composition. Construct groups fluently (group.Add(a, b, c)), but pass them around asTask. This keeps construction ergonomic while preserving the uniform interface for execution. - Make leaf execution simple and self-contained. A
SingleTaskshould do one thing. If a leaf needs setup, work, and teardown, it might actually be a group of three leaves.
Common Mistakes
Putting Add/Remove on the Component interface. In strict GoF, the Component interface includes child management methods so the client can treat leaves and composites identically even for tree manipulation. But in Go, this leads to leaves implementing no-op Add methods or panicking. Better approach: keep Add on the composite type only, and use the shared interface (Task) only for operations that apply to both.
Forgetting that execution order is an implicit contract. A TaskGroup executes children in the order they were added. If “build” must happen before “restart,” that ordering is enforced only by the order of Add() calls – not by the type system. Document the expected ordering, or use named stages if ordering is critical.
Making composites too smart. A TaskGroup that retries failed children, skips certain steps based on conditions, and logs metrics has taken on too many responsibilities. Keep the composite focused on delegation. Retry logic should be a decorator around a task. Conditional execution should be a ConditionalTask leaf. Metrics should be a separate concern.
Using Composite when a flat list would suffice. If your “pipeline” is always a flat sequence of steps with no nesting, []Task with a loop is simpler and more obvious than a composite tree. The pattern earns its keep when nesting is real and variable – groups within groups. Without that, it’s over-engineering.
Final Thoughts
The Composite pattern lets you build tree structures where every node – leaf or branch – satisfies the same interface. The client calls one method on the root, and the tree handles itself recursively. Individual tasks and groups of tasks are interchangeable. Depth is hidden. Composition is the architecture.
Go makes this clean through interfaces and slices. The component is an interface. Leaves implement it directly. Composites hold a []Component and delegate. No abstract classes, no inheritance hierarchies, no visitor infrastructure – just an interface, a slice, and a for loop that calls the interface method on each child.
The pattern works best when the nesting is real – pipelines with sub-pipelines, directories with sub-directories, permission groups with sub-groups. If your structure is flat, keep it flat. But when the tree is genuine, Composite turns complex recursive structures into a single Execute() call.
Treat one and many the same. Let the tree handle itself.