The Factory Method Pattern in Go
Creating Objects Without Tight Coupling

Welcome to the next blog in this series on Software Architecture and Design Patterns. In this post, we’ll explore The Factory Method Pattern, a creational design pattern that focuses on how objects are created, rather than how they are used. Instead of instantiating objects directly, the Factory Method delegates the creation logic to a separate method, helping reduce coupling and improve flexibility.
To understand why this pattern exists, imagine a system that needs to create different types of objects based on some input — for example, generating different kinds of notifications or documents. As the system grows, hard-coded object creation quickly becomes difficult to manage. The Factory Method provides a clean way to encapsulate this creation logic while keeping the rest of the system unaware of the concrete types involved. This separation becomes especially valuable as the number of supported object types grows.
The Problem with Direct Object Creation
In many applications, the simplest way to create an object is to instantiate it directly using a constructor. This approach works well at the beginning, when the system is small and the number of object types is limited. However, as requirements evolve, this simplicity starts to break down.
Consider a system that needs to create different types of objects based on some input or configuration. The usual approach is to use conditional logic — if or switch statements — to decide which concrete type to instantiate. Over time, these conditionals grow, spread across the codebase, and become tightly coupled to concrete implementations.
This leads to code that is hard to extend and even harder to maintain. Adding a new type often requires modifying existing logic, increasing the risk of bugs and violating the Open/Closed Principle. What started as a small and readable block of code slowly turns into a central point of fragility in the system.
To see this more clearly, let’s look at a simple example of this problem in code.
A Naive Implementation (The Problem)
Imagine we’re building a system that sends notifications.
Depending on the type, we create a different notification object for each case.
type EmailNotification struct{}
func (e EmailNotification) Send() {
fmt.Println("Sending Email notification")
}
type SMSNotification struct{}
func (s SMSNotification) Send() {
fmt.Println("Sending SMS notification")
}
type PushNotification struct{}
func (p PushNotification) Send() {
fmt.Println("Sending Push notification")
}
func SendNotification(notificationType string) {
if notificationType == "email" {
email := EmailNotification{}
email.Send()
} else if notificationType == "sms" {
sms := SMSNotification{}
sms.Send()
} else if notificationType == "push" {
push := PushNotification{}
push.Send()
} else {
fmt.Println("Unknown notification type")
}
}
func main() {
SendNotification("email")
SendNotification("sms")
SendNotification("push")
}
Why This Approach Is Problematic
Tight Coupling to Concrete Types
The
SendNotificationfunction directly depends onEmailNotification,SMSNotification, andPushNotification.
Any change to these concrete implementations affects this function.
For example, if theEmailNotificationconstructor later requires additional data (like a template ID or SMTP config), theSendNotificationfunction must be updated to accommodate that change. Similarly, renaming a method or changing the initialization logic of any notification type forces modifications in this function. This tight coupling makes the code rigid and difficult to adapt as the system evolves.Violates the Open/Closed Principle
Every time a new notification type is introduced, the
SendNotificationfunction must be modified.
This means existing, working code needs to be changed just to support a new variation.
Over time, this increases the risk of regressions and bugs.Conditional Logic Keeps Growing
As more notification types are added, the
if-elsechain becomes longer and harder to read.
This logic often gets duplicated across multiple parts of the codebase.
Eventually, object creation becomes scattered and inconsistent.Harder to Test and Maintain
Testing this function requires knowledge of all concrete notification types.
You can’t easily substitute or mock notification behavior without touching the function itself.
Maintenance becomes painful as this single function turns into a central point of failure.
Refactoring Using the Factory Method Pattern
The goal of the Factory Method is simple:
Move object creation logic out of the business code and centralize it behind an abstraction.
Step 1: Define a Common Interface
type Notification interface {
Send()
}
This allows the rest of the system to work with notifications without knowing their concrete types. This means that as long as a type implements this interface, the code will continue to work seamlessly—even if we later extend or modify the behavior of specific concrete implementations.
Step 2: Implement Concrete Products
type EmailNotification struct{}
func (e EmailNotification) Send() {
fmt.Println("Sending Email notification")
}
type SMSNotification struct{}
func (s SMSNotification) Send() {
fmt.Println("Sending SMS notification")
}
type PushNotification struct{}
func (p PushNotification) Send() {
fmt.Println("Sending Push notification")
}
These remain unchanged — the key difference is how they are created.
Step 3: Introduce the Factory Method
This is where object creation is centralized.
func NotificationFactory(notificationType string) (Notification, error) {
switch notificationType {
case "email":
return EmailNotification{}, nil
case "sms":
return SMSNotification{}, nil
case "push":
return PushNotification{}, nil
default:
return nil, fmt.Errorf("unknown notification type: %s", notificationType)
}
}
The factory encapsulates all creation logic in one place.
Step 4: Refactor Business Logic to Use the Factory
func SendNotification(notificationType string) error {
notification, err := NotificationFactory(notificationType)
if err != nil {
return err
}
notification.Send()
return nil
}
Business logic depends only on the Notification interface. No dependency on any concrete implementations.
Step 5: Entire code
type Notification interface {
Send()
}
type EmailNotification struct{}
func (e EmailNotification) Send() {
fmt.Println("Sending Email notification")
}
type SMSNotification struct{}
func (s SMSNotification) Send() {
fmt.Println("Sending SMS notification")
}
type PushNotification struct{}
func (p PushNotification) Send() {
fmt.Println("Sending Push notification")
}
func NotificationFactory(notificationType string) (Notification, error) {
switch notificationType {
case "email":
return EmailNotification{}, nil
case "sms":
return SMSNotification{}, nil
case "push":
return PushNotification{}, nil
default:
return nil, fmt.Errorf("unknown notification type: %s", notificationType)
}
}
func SendNotification(notificationType string) error {
notification, err := NotificationFactory(notificationType)
if err != nil {
return err
}
notification.Send()
return nil
}
func main() {
SendNotification("email")
SendNotification("sms")
SendNotification("push")
}
Why This Refactor Is Better
Creation Logic Is Isolated in One Place
All object creation is centralized inside the factory method instead of being scattered across the codebase.
This ensures that decisions about which concrete type to create and how to initialize it are made in a single location.
When creation rules change, you know exactly where to look.
This containment significantly reduces the chance of unintended side effects.Business Logic Is Cleaner and Easier to Read
The business logic no longer deals with conditionals or concrete implementations.
It simply asks for a notification and performs an action on it.
This makes the intent of the code immediately clear and easier to reason about.
As a result, business workflows remain focused on what the system does, not how objects are created.Concrete Types Are Hidden Behind an Interface
The calling code interacts only with the
Notificationinterface, not specific implementations.
This removes any direct dependency on concrete types likeEmailNotificationorSMSNotification.
Such abstraction allows implementations to change freely (which we will see) without impacting callers. It also makes testing easier by enabling mocks or alternative implementations.
Extending Functionality: Enhanced Email Notification
Earlier, we mentioned a concrete problem:
What if
EmailNotificationlater needs more data, like a template ID or SMTP config?
Let’s implement that now.
Step 1: Extend EmailNotification (New Requirements)
Suppose email notifications now require:
an email template
a sender address
type EmailNotification struct {
templateID string
from string
}
func (e EmailNotification) Send() {
fmt.Printf(
"Sending Email notification using template %s from %s\n",
e.templateID,
e.from,
)
}
Note:
The
Notificationinterface did not changeOnly the concrete implementation evolved
Step 2: Update the Factory (Single Point of Change)
func NotificationFactory(notificationType string) (Notification, error) {
switch notificationType {
case "email":
return EmailNotification{
templateID: "welcome-email",
from: "noreply@example.com",
}, nil
case "sms":
return SMSNotification{}, nil
case "push":
return PushNotification{}, nil
default:
return nil, fmt.Errorf("unknown notification type: %s", notificationType)
}
}
What This Demonstrates Clearly
1. Extension Without Modification (Where It Matters)
The business logic (SendNotification) did not change at all, even though:
EmailNotificationbecame more complexIts construction logic changed
All impact was isolated inside the factory.
2. Concrete Complexity Is Hidden
Details like:
template selection
sender configuration
initialization rules
are completely hidden from the caller.
The caller still just works with a Notification.
3. The Factory Becomes the Stability Boundary
As systems grow:
Concrete implementations change often
Business workflows should not
The factory acts as a buffer between these two worlds.
Formal Definition of Factory Method (Finally!)
The Factory Method Pattern is a creational design pattern that defines an interface for creating an object, while allowing the responsibility of instantiating concrete types to be deferred to a separate creation method. Instead of constructing objects directly, client code relies on abstractions, letting the factory decide which concrete implementation to return based on context or input. This separation ensures that object creation logic is isolated from business logic, making systems easier to extend, modify, and maintain as requirements evolve.
Conclusion
The Factory Method Pattern offers a clean way to separate object creation from business logic, making systems easier to extend as new requirements emerge. By relying on abstractions instead of concrete types, it helps reduce coupling and keeps core workflows stable even as implementations evolve.
This was my third design pattern explored from Head First Design Patterns, and working through it with a hands-on Go example made the motivation behind the pattern much clearer. Breaking the problem first, then refactoring it step by step, really highlighted why this pattern exists and where it fits in real systems. I’ll continue sharing these learnings as I move through more patterns in the series.
Until next time, keep learning with curiosity, building with intent, and breaking things just enough to understand how they really work.



