How to Apply Design Patterns in Golang

July 07, 2025
Written by
Joseph Udonsak
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

Applying design patterns in Golang

In the world of software engineering, design patterns serve as battle-tested solutions to common architectural challenges. Among these, the Singleton and Factory patterns stand out for their practical utility and widespread adoption across various programming paradigms. When implemented in Go—a language known for its simplicity and efficiency —they can significantly enhance application structure and maintainability.

The Singleton pattern ensures that a class has only one instance while providing a global point of access to it. This pattern proves invaluable when exactly one object is needed to coordinate actions across a system. Meanwhile, the Factory pattern creates objects without exposing the instantiation logic to the client, referring to the newly created object through a common interface.

To demonstrate these patterns, you will build a simple application that can send messages via three channels - voice messages, SMS messages, or WhatsApp messages. The messaging functionality will be powered by the Twilio Messaging API.

Prerequisites

To follow this tutorial, you will need the following:

Set up the project

The first thing you need to do is to create a project folder and navigate into it. To do that, run the following commands.

mkdir twilio_notifier_app
cd twilio_notifier_app

Next, create a new Go module by running the following command

go mod init app

Then, add the project's dependencies:

To install them, run the command below.

go get github.com/joho/godotenv github.com/twilio/twilio-go github.com/beevik/etree

Next, create a new file named .env to store the environment variables which the application requires. Then, in the new file paste the following code.

TWILIO_ACCOUNT_SID=<your-account-sid>
TWILIO_AUTH_TOKEN=<your-auth-token>
TWILIO_PHONE_NUMBER=<your-twilio-phone-number>
TWILIO_WHATSAPP_NUMBER=<your-twilio-whatsapp-number>
Ensure that the .env file is not added to version control as this could expose your application secrets.

The next thing you need to do is to retrieve your Twilio Account SID, Auth Token, phone number, and WhatsApp number. To do that:

  • Login to the Twilio Console
  • Copy the details of the first three from the Account Info panel
  • In .env, replace <your-account-sid> , <your-auth-token>, <your-twilio-phone-number> respectively, with the copied details
Screenshot of Account Information on Twilio dashboard containing Account SID, Auth Token and Twilio phone number

If you are using the WhatsApp sandbox, make sure you have added your phone number to the sandbox by following the setup instructions. The phone number to be used in your .env file will also be shown here. Ensure you only copy the phone number (in the format + XXXXXXXXXXX).

A prompt to send a WhatsApp message with a phone number and code, and a button to open WhatsApp.
You also need to ensure that your Twilio phone number has appropriate geo permissions to enable calls to the destinations you need to call.

Create a new folder named notifier at the root of the project folder. This will hold the code for notification-related functionality.

Next, you will create the application notifiers; these are services responsible for sending messages. Your application will have three: SMSNotifier, VoiceNotifier, and WhatsAppNotifier, for notifications via SMS, voice call, and WhatsApp respectively.

In the notifier folder, create a new file named sms.go and add the following code to it.

package notifier

import (
	api "github.com/twilio/twilio-go/rest/api/v2010"
)

type SMSNotifier struct {
}

func (notifier SMSNotifier) Notify(recipientPhoneNumber, message string) (string, error) {
	params := &api.CreateMessageParams{}
	params.SetBody(message)
	params.SetFrom(twilioPhoneNumber)
	params.SetTo(recipientPhoneNumber)
	resp, err := client.Api.CreateMessage(params)
	if err != nil {
		return "", err
	}
	return *resp.Sid, nil
}

Here, you created a new struct named SMSNotifier with one receiver method named Notify. This method takes the recipient’s phone number and the message to be delivered, and makes a request to the Twilio API using the twilio-go library. If an error is encountered, it is returned with an empty string; otherwise the message's SID is returned.

Next, in the notifier folder, create a new file named voice.go and add the following code to it.

package notifier

import (
	api "github.com/twilio/twilio-go/rest/api/v2010"
	"github.com/twilio/twilio-go/twiml"
)

type VoiceNotifier struct {
}

func (notifier VoiceNotifier) Notify(recipientPhoneNumber, message string) (string, error) {
	params := &api.CreateCallParams{}
	params.SetFrom(twilioPhoneNumber)
	params.SetTo(recipientPhoneNumber)
	sayMessage, err := sayTwiml(message)
	if err != nil {
		return "", err
	}
	params.SetTwiml(sayMessage)
	resp, err := client.Api.CreateCall(params)
	if err != nil {
		return "", err
	}
	return *resp.Sid, nil
}

func sayTwiml(message string) (string, error) {
	say := &twiml.VoiceSay{
		Message: message,
	}
	verbList := []twiml.Element{say}
	sayTwiml, err := twiml.Voice(verbList)
	if err != nil {
		return "", err
	}
	return sayTwiml, nil
}

Your implementation here is similar to the one for sending SMS notifications. You have a VoiceNotifier struct with a receiver method named Notify which takes the same parameters, makes an API call, and returns a message SID if the request was handled successfully.

There’s a slight difference, however, in the content of the request. For your voice messages, you used TwiML to create an instruction for Twilio. This instruction is to read the typed message when the recipient answers the phone call. You created this instruction using the sayTwiml() function.

The last notifier to create is the one for sending WhatsApp notifications. In the notifier folder, create a new file named whatsApp.go and add the following code to it.

package notifier

import (
	"fmt"
	api "github.com/twilio/twilio-go/rest/api/v2010"
)

type WhatsAppNotifier struct {
}

func (notifier WhatsAppNotifier) Notify(recipientPhoneNumber, message string) (string, error) {
	params := &api.CreateMessageParams{}
	params.SetFrom(fmt.Sprintf("whatsapp:%s", twilioWhatsAppNumber))
	params.SetTo(fmt.Sprintf("whatsapp:%s", recipientPhoneNumber))
	params.SetBody(message)
	resp, err := client.Api.CreateMessage(params)
	if err != nil {
		return "", err
	}
	return *resp.Sid, nil
}

If at this point you have an error with the github.com/twilio/twilio-go/twiml import, update your project dependencies by running the following command to add any indirect dependencies.

go mod tidy

Similar to what has been done in the previous two notifiers, a struct named WhatsAppNotifier is created with a receiver method named Notify. This method takes the recipient phone number and message, then sends a request to  Twilio's Programmable Messaging API. Since this is a WhatsApp message request, the from and to phone numbers are prepended with whatsapp:

With the notifiers in place, the next thing to do is create an interface which will be exposed to other parts of the application, and a factory function to return the appropriate interface implementation based on a specified criteria in this case, the notification medium. In the notifier folder, create a new file named notifier.go and add the following code to it.

package notifier

import (
	"fmt"
	_ "github.com/joho/godotenv/autoload"
	"github.com/twilio/twilio-go"
	"os"
)

var (
	client               = twilio.NewRestClient()
	twilioPhoneNumber    = os.Getenv("TWILIO_PHONE_NUMBER")
	twilioWhatsAppNumber = os.Getenv("TWILIO_WHATSAPP_NUMBER")
)

type Notifier interface {
	Notify(recipientNumber, message string) (string, error)
}

func GetNotifier(medium string) (Notifier, error) {
	switch medium {
	case "sms":
		return SMSNotifier{}, nil
	case "voice":
		return VoiceNotifier{}, nil
	case "whatsApp":
		return WhatsAppNotifier{}, nil
	default:
		return nil, fmt.Errorf("unsupported medium %s provided", medium)
	}
}

func GetSupportedMedia() []string {
	return []string{
		"sms",
		"voice",
		"whatsApp",
	}
}

Using the autoload package made available by GoDotEnv, the variables in .env are loaded on import. Next, the code initiated the Twilio REST client using the NewRestClient() function. This function retrieves the Twilio SID and Auth Token from your environment variables and creates a new client for you. Next, it retrieved your Twilio phone number and WhatsApp number from your environment variables. 

Then, it declared an interface named Notifier which has a single function named Notify(). This function takes a phone number and a message, and returns a string and an error. All the notifiers declared earlier implement the Notifier interface because they have a single method with the same signature as the Notify() function.

After that, it declares a function named GetNotifier() which takes a string for the notification medium and returns a notifier for the specified medium. Notice that the method signature returns an interface and not a concrete notifier. Finally, the GetSupportedMedia() function returns all the currently supported means of sending notifications.

With this in place, it’s time to create the templates for rendering the application frontend.

Create the view templates

For the frontend, you will use Go templates to render HTML pages. Create a new folder named template at the root of the project folder. In the template folder, create a new file named base.html and add the following code to it.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Twilio Notifier App</title>
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link
      href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
      rel="stylesheet"
      integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
      crossorigin="anonymous"
    />
  </head>
  <body>
    <div style="width: 41%; margin: 0 auto; padding-top: 5%">
      <h1 class="display-4">Twilio Notifier App</h1>
    </div>
    
    {{if (ne .Error "") }}
    <div class="alert alert-danger alert-dismissible fade show" role="alert">
      <strong>{{.Error}}</strong>
      <button
        type="button"
        class="btn-close"
        data-bs-dismiss="alert"
        aria-label="Close"
      ></button>
    </div>
    {{ end }} 

    {{if (ne .Message "") }}
    <div class="alert alert-success alert-dismissible fade show" role="alert">
      <strong>{{.Message}}</strong>
      <button
        type="button"
        class="btn-close"
        data-bs-dismiss="alert"
        aria-label="Close"
      ></button>
    </div>
    {{ end }} 
    
    {{ template "body" . }}
    <script
      src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
      integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
      crossorigin="anonymous"
    ></script>
    <script
      src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js"
      integrity="sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r"
      crossorigin="anonymous"
    ></script>
    <script
      src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js"
      integrity="sha384-0pUGZvbkm6XF6gxjEnlmuGrJXVbNuzT9qBBavbLwCsOGabYfZo0T0to5eqruptLy"
      crossorigin="anonymous"
    ></script>
  </body>
</html>

This template imports Bootstrap for styling, creates a slot for the body of a page (which is defined in a separate template), and renders an error or success alert if available in the template context (which we will create later).

Next, in the template folder, create a new file named index.html which renders the index page. Add the following code to the newly created file.

{{define "body"}}
<div class="container-fluid" style="width: 80%; margin: auto; padding: 5%">
  <form action="/notify" method="post">
    <div class="mb-3">
      <input
        type="text"
        class="form-control"
        placeholder="Recipient phone number (in E.164 format)"
        name="recipient"
      />
    </div>
    <div class="mb-3">
      <select name="medium" class="form-select">
        {{range .Data}}
        <option {{.}}>{{.}}</option>
        {{end}}
      </select>
    </div>
    <div class="mb-3">
      <input
        type="text"
        class="form-control"
        placeholder="Message"
        name="message"
      />
    </div>
    <button type="submit" class="btn btn-primary">Submit</button>
  </form>
</div>
{{end}}

This renders a form which allows the user to provide a phone number (in E.164 format) and a notification medium. On submit, a request will be sent to the backend and the message will be sent to the user via the specified medium.

Create the route's handler

Having created the templates, you need to create handler functions to do the following:

  1. Render the index page

  2. Handle the request to send a notification

To keep your handler functions concise, you can create some helper functions for validation and template rendering. 

Create a new folder named handler at the root of the project folder. In the handler folder, create a new file named helper.go and add the following code to it.

package handler

import (
	"errors"
	"fmt"
	"html/template"
	"log"
	"net/http"
	"regexp"
)

type TemplateContext struct {
	Error   string
	Data    any
	Message string
}

var context *TemplateContext = &TemplateContext{
	Error:   "",
	Data:    nil,
	Message: "",
}

func clearContext() {
	context.Error = ""
	context.Message = ""
}

func render(w http.ResponseWriter, templateName string) {
	t, err := template.ParseFiles("template/base.html", fmt.Sprintf("template/%s.html", templateName))
	check(err)
	err = t.Execute(w, context)
	check(err)
}

func renderError(err error, w http.ResponseWriter, templateName string) {
	context.Error = err.Error()
	render(w, templateName)
}

func check(err error) {
	if err != nil {
		log.Fatal(err)
	}
}

func validatePhoneNumber(phoneNumber string) error {
	match, err := regexp.Match(`^\+[1-9]\d{1,14}$`, []byte(phoneNumber))

	if err != nil {
		return err
	}
	if !match {
		return errors.New("phone number must be in E.164 format")
	}
	return nil
}

Here, you declared a struct named TemplateContext which is used by the template to render errors, success messages; or display data returned from the backend. Next, you created a pointer instance of the TemplateContext which will be used throughout the application — known as a singleton. 

The clearContext() function resets the error and success message values to empty strings. The render() function is used to render a template while the renderError() populates the error message in the TemplateContext before rendering the template. The check() function logs any errors encountered and kills the application. 

Finally, the validatePhoneNumber() function ensures that the provided phone number matches against the regular expression for a valid E.164 phone number. In the event that the provided phone number is invalid, an error is returned with an appropriate error message.

Regex (regular expression) validation is used in this article for the sake of simplicity. You could use the Twilio Lookup API for a more robust validation.

Next, in the handler folder, create a new file named handler.go and add the following code to it

package handler

import (
	"app/notifier"
	"fmt"
	"net/http"
)

func index(w http.ResponseWriter, r *http.Request) {
	clearContext()
	context.Data = notifier.GetSupportedMedia()
	render(w, "index")
}

func notify(w http.ResponseWriter, r *http.Request) {
	clearContext()

	r.ParseForm()
	medium := r.FormValue("medium")
	recipient := r.FormValue("recipient")
	message := r.FormValue("message")

	if err := validatePhoneNumber(recipient); err != nil {
		renderError(err, w, "index")
		return
	}

	notifier, err := notifier.GetNotifier(medium)

	if err != nil {
		renderError(err, w, "index")
		return
	}

	response, err := notifier.Notify(recipient, message)

	if err != nil {
		renderError(err, w, "index")
		return
	}

	context.Message = fmt.Sprintf("Message delivered successfully with SID: %s", response)

	render(w, "index")
}

func GetRouter() *http.ServeMux {
	mux := http.NewServeMux()

	mux.HandleFunc("/", index)
	mux.HandleFunc("/notify", notify)
	return mux
}

The index() function is responsible for rendering the index page. This function clears the TemplateContext, sets the context data to the supported notification media, and renders the index.html template you created earlier. 

The notify() function is responsible for handling the submitted form and sending the notification. This function retrieves the recipient phone number and notification medium from the parsed form. Next, it validates the phone number using the validatePhoneNumber() function you declared earlier. 

Then, it retrieves the appropriate notifier based on the submitted form and tries to send a notification. Observe that the notify() function is decoupled from the concrete notifiers. This means that adding or removing a notifier will not require any modification of this function. This is the major benefit that the Factory design pattern affords you. 

At any point, if an error is returned, this error is rendered and the user can refill and resubmit the form. If everything works as expected, the context message is populated and the index page is re-rendered to show the success message, allowing the user to send another notification if so desired. 

Finally, the GetRouter() function creates a new Mux which links the specified paths to the appropriate handler function. 

Put it all together

Finally, create a new file named main.go at the root of the folder and add the following code to it.

package main

import (
	"app/handler"
	"fmt"
	"net/http"
)

func main() {
	fmt.Println("Running on port 8080")
	http.ListenAndServe(":8080", handler.GetRouter())
}

Here, your application is served on port 8080. Now, run the application with the following command.

go run main.go

Opening http://localhost:8080 in your browser, it should look similar to  the screen recording below.

Screenshot of Twilio Notifier App with fields for phone number, message type, and message content.

That's how to apply design patterns in Go

There you have it! I bet it was easier than you expected. You can review the final codebase for this article on GitHub, should you get stuck at any point. I’m excited to see what else you come up with. Until next time, make peace not war✌🏾

Joseph Udonsak is a software engineer with a passion for solving challenges — be it building applications, or conquering new frontiers on Candy Crush. When he’s not staring at his screens, he enjoys a cold beer and laughs with his family and friends. Find him at LinkedIn, Medium, and Dev.to.