In this example we look at a notification system that can send notifications as an sms and email.
Let's have a look at the main
function:
func main() {
//create smsNotifier object
var smsSender smsNotifier
smsSender.initialize("the-sms-api-config-details")
//create emailNotifier object
var smtpSender emailNotifier
smtpSender.initialize("the-smtp-details-to-use")
//create notificationManager object and
//register which notifiers must be used
var nm notificationManager
nm.registerSMSNotifier(&smsSender)
nm.registerSMTPNotifier(&smtpSender)
//add some notifications
nm.addNotification(notification{ /*...*/ })
nm.addNotification(notification{ /*...*/ })
nm.addNotification(notification{ /*...*/ })
nm.addNotification(notification{ /*...*/ })
nm.addNotification(notification{ /*...*/ })
//send them off
nm.processNotifications()
}
Nothing fancy here. Basically the email and sms objects that will be used by the notification manager are created. Then we create the notification manager and set the notifiers it will use. Then we add some notifications and call the method to do the processing.
Here's the basic types:
//notification defines the concrete data type that
//will be used in the notification system
type notification struct {
recipient string
message string
subject string
//other fields associated with notification
}
//smsNotifier is used to send the notification as an SMS
type smsNotifier struct {
//fields specific to sending SMS's
}
func (e *smsNotifier) initialize(config string) {
//setup configuration specific to send SMS
}
func (e smsNotifier) sendNotification(n notification) {
//logic to send SMS
}
//emailNotifier is used to send the notification as an email
type emailNotifier struct {
//fields specific to sending emails
}
func (e *emailNotifier) initialize(config string) {
//setup configuration specific to send emails
}
func (e emailNotifier) sendNotification(n notification) {
//logic to send email
}
The notificationManager object is where the notifications are collected and the sending is coordinated:
//notificationManager collects notifications and
//send the notification as an email and/or sms, depending
//on which notifiers are registered
type notificationManager struct {
*smsNotifier
*emailNotifier
notifications []notification //slice of notifications to process
//rest of fields
}
func (n *notificationManager) registerSMSNotifier(s *smsNotifier) {
n.smsNotifier = s
}
func (n *notificationManager) registerEmailNotifier(e *emailNotifier) {
n.emailNotifier = e
}
func (n *notificationManager) addNotification(nf notification) {
//adds a notification to the notifications slice
}
//processNotifications loops over the given notifications
//and send them as email and/or sms
func (n *notificationManager) processNotifications() {
//read through notifications to send
for _, nf := range n.notifications {
//check if sms notifier is set
if n.smsNotifier != nil {
n.smsNotifier.sendNotification(nf)
}
//check if email notifier is set
if n.emailNotifier != nil {
n.emailNotifier.sendNotification(nf)
}
}
}
So this all works and basically is doing what it should do according to the spec. We've made it work. But it needs some refactoring.
What if the client tells us that there's an Android app out and we need to replace the sms notifications with mobile app push notifications?
Hhmm, if there's an Android app out, then there's a good chance that in the near future the client would want to send out Apple notifications as well.
To make these changes we would need to create a new notifier: androidNotifier
Also, notificationManager will need to change as well. The new
notifier object need to be added, smsNotifier
need to be removed,
and the relevant registerAndroidNotifier
method need to be added and the setSMSNotifier
method must go.
In addition to that the processNotifications
method also need to change.
That's quite a bit of change, and the possibility of another new notifier type (Apple) in the near future is good.
Already we see that there's some code coupling and bringing change to the system require changes to many parts of the system. The more change we bring the greater the risk that existing things will break.
Let's see how we can make the design better and more adaptive to change?
By looking at the methods of the notifier types we see some
common behavior: the sendNotification
method.
There's also duplication in the register*Notifier
method in the
notificationManager
type.
Let's have a look at the notificationManager
type.
We can spot duplication in the properties (smsNotifier
and emailNotifier
types) as well as
in the register*Notifier
methods and processNotifications
method.
All this duplication and common behavior can be cleaned up by creating and using an interface
type.
Time for an interface
adventure...
type notifier interface {
sendNotification(notification)
}
We create an interface type and call it notifier
. The new
type have one method called sendNotification
.
All the notifier concrete types satisfy this interface as all of them
have a method called sendNotification
.
Now we can change the notificationManager
to have a slice of
the interface type notifier
instead of using the concrete types:
type notificationManager struct {
notifiers []notifier //slice of registered notifiers
notifications []notification //slice of notifications to process
//rest of fields
}
By doing that we can reduce our register*Notifier
methods to one method
called registerNotifier
that accepts a notifier
type and adds it to the
slice of notifiers:
func (n *notificationManager) registerNotifier(N notifier) {
//adds notifier to notifiers slice
}
We can also clean up processNotifications
by removing the multiple if
statements that check if a particular notifier is registered and rather loop over
the slice of notifications and call their underlying sendNotification
method:
func (n notificationManager) processNotifications() {
//read through notifications to send
for _, nf := range n.notifications {
//loop through all notifiers and send notification
for _, N := range n.notifiers {
N.sendNotification(nf)
}
}
}
If the client request the
anticipated Apple notification then we simply add a new concrete type
called appleNotifier
with it's sendNotification
method.
From the calling main
function we create a new object of the appleNotifier
type
and simply register it by calling the registerNotifier
method.
Notice that there are absolutely no change to the notificationManager
type when this happens.
Also notice that after we created the new notifier
interface type that we
did not have to add a keyword implements
to the existing concrete types (i.e emailNotifier
) because
of Go's implicit satisfaction of interface types.
Our design are more adaptive to change with less breaking risk if there's a change.
This feels right..