Elegant and Easy: Unleashing the Power of the Composite Pattern in Go

Introduction

The composite pattern allows you treat a group of objects like a single object. The objects are composed into some form of a tree-structure to make this possible.

This patterns solves two problems:

  • Sometimes it is practical to treat part and whole objects the same way
  • An object hierarchy should be represented as a tree structure

We do this by doing the following:

  • Define a unified interface for both the part objects (Leaf) and the whole object (Composite)
  • Composite delegate calls to that interface to their children, Leaf objects deal with them directly.

This all sounds rather cryptic, so let us have a look at the diagram:

This is basically a graphical representation of the last two points: Composites delegate and Leafs perform the actual operation.

Implementation in Go

In this example we will deal with a country, and provinces. We will start with the usual preliminaries:

package main

import "fmt"

Next we define a GeographicalEntity interface, with just one method:

type GeographicalEntity interface {
	search(string)
}

Next we define the Country struct:

type Country struct {
	provinces []GeographicalEntity
	name      string
}

func newCountry(name string) Country {
	return Country{
		provinces: make([]GeographicalEntity, 0),
		name:      name,
	}
}

func (c Country) search(term string) {
	fmt.Printf("Search for city %s in country %s\n", term, c.name)
	if c.name == term {
		fmt.Printf("Found country: %s\n", term)
	}
	for _, composite := range c.provinces {
		composite.search(term)
	}
}

func (c *Country) addProvince(province GeographicalEntity) {
	c.provinces = append(c.provinces, province)
}

Some notes:

  1. A country consists of some provinces. That is where the provinces variable comes from
  2. A country also has a name
  3. The newCountry function is a utility function to construct a new Country struct
  4. In the search we iterate over the provinces field. Since the objects implement the search() method, we delegate our search-request to them. Note that any object that satisfies the GeographicalEntity interface could be in that array. Also note that if the country name happens to be the same as the search term, this is also reported.
  5. The addProvince simply adds a province, or to be precise an object satisfying the GeographicalEntity interface, to the provinces slice.

Next we implement the Province:

type Province struct {
	name string
}

func newProvince(name string) Province {
	return Province{
		name: name,
	}
}

func (p Province) search(term string) {
	fmt.Printf("Search for city %s in province %s\n", term, p.name)
}

func (p *Province) addCity(city GeographicalEntity) {
	p.cities = append(p.cities, city)
}

Also some notes here:

  1. A Province has one name, and list of cities.
  2. The newProvince() function is a utility function to create a Province struct
  3. In the search() method we simply announce we are searching.
  4. The addCity() method adds a city to the cities-list.

Finally we need a City which will act as the leaf node of our composite structure:

type City struct {
	name string
}

func newCity(name string) City {
	return City{name: name}
}

func (c City) search(term string) {
	if c.name == term {
		fmt.Printf("Found city: %s\n", term)
	}
}

Some notes:

  1. Since City is the leaf node it does not have any references to children, and just has a name.
  2. Like our previous structs this struct has a constructor function: newCity()
  3. Since there are no children, the search() method is extremely simple: simple compare the search term to the city’s name, and print a message if they match.

Time to test

Time to set up a small geographical database, and see if we can search it:

func main() {
	city1 := newCity("city1")
	city2 := newCity("city2")
	province1 := newProvince("province1")
	province1.addCity(city1)
	province1.addCity(city2)
	province2 := newProvince("province2")

	country := newCountry("Country")
	country.addProvince(province1)
	country.addProvince(province2)

	country.search("city1")
}

Line by line:

  1. We start by constructing two cities, and one province.
  2. Add these cities to the province.
  3. Then create a second province without any cities.
  4. Now add a new country, and add these two provinces.
  5. Finally we can perform a search.

Conclusion

The Composite Pattern, as demonstrated through the simple, elegant power of Go interfaces, is a truly indispensable tool for managing hierarchical data structures.

Key Takeaways

  • Unified Treatment: By defining the GeographicalEntity interface, we achieved the core goal of the pattern: treating individual objects (Leaves) like City and composite objects (Composites) like Country and Province uniformly. This simplifies client code, as you only interact with the unified interface, regardless of the object’s complexity.
  • Recursive Operations: The true magic of the Composite Pattern lies in its recursive nature. When we called country.search("city1"), the request elegantly cascaded down the hierarchy—from Country to Province to City—until the leaf node performed the final, atomic operation.
  • Flexibility and Extensibility: This design makes the system highly flexible. Adding a new hierarchical level (e.g., a “Region” that contains multiple “Countries”) or a new leaf type (e.g., a “Village”) simply requires implementing the GeographicalEntity interface. The existing Country and Province logic remains untouched, adhering to the Open/Closed Principle.

The Composite Pattern in Go allows developers to build complex tree structures that are easy to navigate, understand, and maintain. It’s a testament to how simple object-oriented principles, leveraged by Go’s powerful interface system, can solve complex architectural problems with clean, maintainable code.

The Code Nomad
The Code Nomad
Articles: 165

Leave a Reply

Your email address will not be published. Required fields are marked *