Skip to main content

Command Palette

Search for a command to run...

The Factory Method Pattern in Go

Creating Objects Without Tight Coupling

Published
8 min read
The Factory Method Pattern in Go

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

  1. Tight Coupling to Concrete Types

    The SendNotification function directly depends on EmailNotification, SMSNotification, and PushNotification.
    Any change to these concrete implementations affects this function.
    For example, if the EmailNotification constructor later requires additional data (like a template ID or SMTP config), the SendNotification function 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.

  2. Violates the Open/Closed Principle

    Every time a new notification type is introduced, the SendNotification function 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.

  3. Conditional Logic Keeps Growing

    As more notification types are added, the if-else chain becomes longer and harder to read.
    This logic often gets duplicated across multiple parts of the codebase.
    Eventually, object creation becomes scattered and inconsistent.

  4. 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 Notification interface, not specific implementations.
    This removes any direct dependency on concrete types like EmailNotification or SMSNotification.
    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 EmailNotification later 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 Notification interface did not change

  • Only 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:

  • EmailNotification became more complex

  • Its 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.