Skip to Content
Course content

242: Practice Exercise: Building a Plugin System with Interfaces

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've spent a lot of my career maintaining legacy systems where adding a single new feature felt like playing Jenga. You touch one thing, and three unrelated modules collapse. Usually, this happens because the core logic knows way too much about the specific details of its peripherals. Today, I want to show you how to solve that using interfaces to build a plugin system.

The Switch Statement Trap

Let's say we're building a data reporting tool. I start by writing a simple function that exports data based on a format string. It feels intuitive at first.

type Report struct {
    Title   string
    Content string
}

func ExportReport(r Report, format string) {
    if format == "csv" {
        fmt.Printf("Exporting %s as CSV...\n", r.Title)
        // CSV logic here
    } else if format == "json" {
        fmt.Printf("Exporting %s as JSON...\n", r.Title)
        // JSON logic here
    } else {
        fmt.Println("Unsupported format")
    }
}

This works for two formats. But imagine we have twenty. Or worse, imagine these formats are defined by different teams in different packages. I'd have to import every single exporter package into my main logic, and every time someone adds a new format, I have to modify this ExportReport function. That's a violation of the Open/Closed Principle, and it's a recipe for merge conflicts.

Defining the Contract

I need a way to tell the core logic: "I don't care how you export the data, as long as you have a method that takes a Report and returns an error." This is where the interface comes in. I'll define a contract that any "plugin" must satisfy.

type Exporter interface {
    Export(r Report) error
}

Now, instead of a switch statement, I can just accept the interface. I'll try implementing a CSV version to see if it feels right.

type CSVExporter struct{}

func (c CSVExporter) Export(r Report) error {
    fmt.Printf("Writing %s to a CSV file...\n", r.Title)
    return nil
}

type JSONExporter struct{}

func (j JSONExporter) Export(r Report) error {
    fmt.Printf("Writing %s to a JSON file...\n", r.Title)
    return nil
}

Now my main function looks like this: func ExportReport(r Report, e Exporter). It's much cleaner. But there's still a problem: someone, somewhere, still has to decide which concrete struct (CSVExporter or JSONExporter) to instantiate. I want the core system to be completely oblivious to the concrete types.

Decoupling via a Registry

To make this a true plugin system, I'll create a registry. Think of it as a map where plugins can "sign up" to be used. I'll use a map of strings to interfaces.

var registry = make(map[string]Exporter)

func RegisterExporter(name string, e Exporter) {
    registry[name] = e
}

func GetExporter(name string) (Exporter, error) {
    e, ok := registry[name]
    if !ok {
        return nil, fmt.Errorf("exporter %s not found", name)
    }
    return e, nil
}

Wait, if I put this in my main package, I'm still manually calling RegisterExporter in main(). To make it really feel like a plugin, I want the plugins to register themselves. In Go, the init() function is perfect for this. It runs before main().

If I move CSVExporter into its own package (let's call it csvplugin), I can do this:

package csvplugin

import "myproject/exporter"

type CSVExporter struct{}
func (c CSVExporter) Export(r Report) error { /* ... */ }

func init() {
    exporter.RegisterExporter("csv", CSVExporter{})
}

Tying it all together

Now, the magic happens. In my main application, I just need to import the plugin packages. I don't even need to call their functions; the init() functions handle the registration behind the scenes. I use a "blank import" (the underscore) to tell Go, "I need the side effects of this package, but I'm not calling its code directly."

import (
    _ "myproject/csvplugin"
    _ "myproject/jsonplugin"
)

func main() {
    report := Report{Title: "Quarterly Sales", Content: "..."}
    
    // The user tells us what they want
    format := "csv" 
    
    exp, err := GetExporter(format)
    if err != nil {
        log.Fatal(err)
    }
    
    exp.Export(report)
}

I've just moved the knowledge of "what exists" out of my business logic and into the plugins themselves. If I want to add an XML exporter tomorrow, I just create a new package, implement the Exporter interface, and add one import line. The core logic never changes. That's how you build a system that actually scales.




📋 Practical Task

Exercise: Implementing a Multi-Gateway Payment System

You are building a checkout system that needs to support multiple payment providers (like Stripe, PayPal, and Square). Instead of hardcoding each provider, you need to implement a plugin-based architecture using interfaces.

Your requirements:

  • Define a PaymentProcessor interface with a method ProcessPayment(amount float64) error.
  • Create a PaymentRegistry that allows processors to be registered by a string name (e.g., "stripe").
  • Implement two concrete processors: StripeProcessor and PayPalProcessor. Each should simply print a message saying "Processing [amount] via [Provider]..." and return nil.
  • In your main function, simulate a user choosing a provider via a variable. Use the registry to fetch the correct processor and execute the payment.
  • Challenge: Ensure that if a user requests a provider that hasn't been registered, your system returns a clear, descriptive error rather than panicking.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.