12 August 2021

SOLID: The Open/Closed Principle (OCP)

Software entities should be 'open for extension but closed for modification'.

Introduction

Robert C. Martin coined the mnomonic acronym SOLID to represent 5 principles of software design. This is the second in my own five-part series exploring my understanding of and experience applying these principles:

  1. [SRP] The Single-Responsibility Principle:
    • "Software entities should have only one reason to change."
  2. [OCP] The Open–Closed Principle: (you are here)
    • "Software entities should be 'open for extension but closed for modification'."
  3. [LSP] The Liskov Substitution Principle:
    • "Subtypes must be substitutable for their base types."
  4. [ISP] The Interface Segregation Principle:
    • "Clients should not be forced to depend on methods they do not use."
  5. [DIP] The Dependency Inversion Principle:
    • "High-level policy should depend upon abstractions, not concretions."

OCP

OCP might just be my favorite SOLID principle (except that DIP is sooo good too)! If you are following SRP then you are probably going to be setup very nicely indeed to take advantage of OCP. At first, this principle seems impossible to achieve. How can a software entity be open for extension but closed for modification? Don't you have to modify things to extend them? If you are thinking more long-term about how future-you or other developers might want to use the system in the future you start to think of ways to provide extension 'hooks'. Here are a few OCP techniques I've used:

Functional Options

The prominent Go developer Dave Cheney has a great blog post on 'functional options', which provide a great way for a caller to choose how a component will be configured, and even provide their own custom configuration options. Consider this example, from an open-source test library I built for Go called gunit:

gunit.Run(t, new(TestSuite),
	gunit.Options.RunSequential(), // <-- functional option
	gunit.Options.LongRunning(),   // <-- functional option
	myTotallyCustomOption(),       // <-- functional option (external to gunit!)
)

The code above runs a test suite according to supplied (optional) parameters. In this case each test will be run sequentially (as opposed to concurrently), and markes each test case as 'long-running' (and may even skip them if the -short flag is passed at the command line). Also notice the myTotallyCustomOption not provided by the gunit library. Since this function's return value provides the expected type, gunit accepts it and will invoke the option as part of executing the test suite. Via functional options, gunit is 'open for extension, closed for modification'.

Multi-Handler Pattern

Interfaces are a powerful tool for building useful abstractions. In the example below, a simple Handler interface allows for any number of implementations of that interface to be chained in any order (or even nested in interesting ways):

type Handler interface{
	Handle(m Message)
}
func (this *InputHandler)  Handle(m Message) { /* does input stuff...  */ }
func (this *OutputHandler) Handle(m Message) { /* does output stuff... */ }
func (this *MultiHandler)  Handle(m Message) {
	for _, handler := range this.handlers {
		handler.handle(m)
	}
}
func main() {
	handler := MultiHandler{
		handlers: []Handler{
			new(InputHandler),
			new(OutputHandler),
		}
	}
	listenForevor(handler)
}

Open for extension, closed for modification!'

Pipelines

A variation on the theme above is made possible with Go's concurrency model and channels:

func (this *Pipeline) Run() (out chan contracts.Article) {
	out = this.goLoad()
	out = this.goListen(out, NewFileReadingHandler(...))
	out = this.goListen(out, NewMetadataParsingHandler(...))
	out = this.goListen(out, NewMetadataValidationHandler(...))
	out = this.goListen(out, NewDraftFilteringHandler(...))
	out = this.goListen(out, NewFutureFilteringHandler(...))
	out = this.goListen(out, NewContentConversionHandler(...))
	out = this.goListen(out, NewArticleRenderingHandler(...))
	out = this.goListen(out, NewTopicPageRenderingHandler(...))
	out = this.goListen(out, NewHomePageRenderingHandler(...))
	return out
}

The above code is actually a snippet from hugoinho, the open-source project that generated the HTML you are currently reading. Each of the 'Handler's listed above is chained together by the following functions:

func (this *Pipeline) goLoad() (out chan contracts.Article) {
	out = make(chan contracts.Article)
	go NewPathLoader(this.disk, this.config.ContentRoot, out).Start()
	return out
}

func (this *Pipeline) goListen(
	in chan contracts.Article, 
	handler contracts.Handler,
) (out chan contracts.Article) {
	out = make(chan contracts.Article)
	go Listen(in, out, handler)
	return out
}

Each handler is very limited in scope and behavior. I can easily change the ordering of the handlers, remove deprecated handlers, or add new ones. Open for extension, closed for modification'


Next Section: The Liskov Substitution Principle