In my last post, I wrote about the benefits of designing your applications in a composable way by the means of being able to substitute commonly-used functions based on randomness with deterministic alternatives for stress-free testing. This is by far not the only use case for providing interchangeable logic to parts of your system which don't care about the implementation of said features, as long as the result is valid.
As we get to building more and more features in a completely pluggable way, we'll start to require some context for certain implementations, for example passing options for one specific way of generating an ID, like the seed for our pseudo-random source we'll use for testing.
type Generator struct {
Seed string
}
type (g *Generator) ID() string {
// Generate an ID
return "some-random-id"
}
Now it starts to get confusing when trying to make every implementation look the same. What we actually want to do here is to use an interface! Interfaces are the idiomatic way
of defining similar methods on varying implementations, you've probably used them already, with some popular interfaces including io.Reader
and io.Writer
.
Let's put it like this: If we were to build two implementations for generating an ID, one completely random one and another one that will keep a counter to simply increase for every generated ID (making it completely deterministic), we'd first define an interface that contains a method to generate our identifier, nothing else.
type Generator interface {
ID() string
}
Now, we'd create our two implementations, which are simple Go structs, both containing the same method we defined on the interface
// An ID generator that will produce a
// completely random result
type RandomGenerator struct {}
func (g *RandomGenerator) ID() string {
return uuid.New()
}
// An ID generator that will produce a
// predictable result based on an
// internal counter variable
type DeterministicGenerator struct {
counter int
}
func (g *RandomGenerator) ID() string {
g.counter += 1
return fmt.Sprintf("%06d", g.counter)
}
This leaves us with two generators that don't share any internal information except the fact that they're implementing the Generator interface. In the code that will consume a generated identifier, we'll simply use the interface type instead of a specific implementation now:
func CreateOrder(g Generator) {
identifier := g.ID()
// ...
}
And that's all we need. Now we're able to build completely isolated implementations of the same feature, allowing us to build applications without being locked into one, and only one, way of doing things.
I hoped you enjoyed this post and could learn a thing or two! If you've got any questions, suggestions, or feedback in general, don't hesitate to reach out on Twitter or by mail.