Skip to Content
Course content

241: Practice Exercise: Building a Plugin-Style Interface Registry

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

You've likely run into a situation where your switch statements are getting out of control. Maybe you're handling different file formats, different payment gateways, or different database drivers. Every time you add a new one, you have to go back to that one central block of code and add another case. It's tedious, and it violates the Open/Closed Principle.

The way we solve this in Go is by building a registry. Instead of the main logic knowing about every possible implementation, we create a central "phone book" where implementations can register themselves. I'm going to show you how to build this using a payment provider example.

Defining the Common Interface

First, we need a contract. Every payment provider we add must behave the same way, or the registry is useless. I'll start by defining a simple PaymentProcessor interface.

type PaymentProcessor interface {
    Process(amount float64) error
}

Now, I can create a few concrete implementations. Let's do a Stripe one and a PayPal one. In a real project, these would probably live in their own packages.

type StripeProcessor struct{}
func (s *StripeProcessor) Process(amount float64) error {
    fmt.Printf("Processing $%.2f via Stripe\n", amount)
    return nil
}

type PayPalProcessor struct{}
func (p *PayPalProcessor) Process(amount float64) error {
    fmt.Printf("Processing $%.2f via PayPal\n", amount)
    return nil
}

Creating the Global Registry

Now for the core logic. I need a place to store these. A map is the obvious choice here, where the key is a string (the provider name) and the value is the interface.

var registry = make(map[string]PaymentProcessor)

func Register(name string, processor PaymentProcessor) {
    registry[name] = processor
}

func GetProcessor(name string) (PaymentProcessor, error) {
    p, ok := registry[name]
    if !ok {
        return nil, fmt.Errorf("processor %s not found", name)
    }
    return p, nil
}

Wait, I forgot about the state

Here is where I usually trip up the first time I build these. In the code above, I'm registering a singleton instance of the processor. For a simple stateless processor, that's fine. But what if my StripeProcessor needs a unique API key per request, or needs to maintain some internal state that shouldn't be shared across the whole app?

If I register a single instance, every part of my app is sharing that one object. That's a recipe for race conditions and weird bugs. I should be registering a factory function instead of the instance itself.

Let me pivot. I'll change the map to store functions that return a processor.

// The new registry type: a map of factory functions
var registry = make(map[string]func() PaymentProcessor)

func Register(name string, factory func() PaymentProcessor) {
    registry[name] = factory
}

func GetProcessor(name string) (PaymentProcessor, error) {
    factory, ok := registry[name]
    if !ok {
        return nil, fmt.Errorf("processor %s not found", name)
    }
    // We call the factory here to get a fresh instance
    return factory(), nil
}

Wiring it up with init() functions

To make this feel like a real "plugin" system, I don't want my main.go to have to manually call Register() for every single provider. That defeats the purpose of decoupling.

Instead, I'll use Go's init() function. Since init() runs automatically when a package is initialized, the provider can "announce" itself to the registry just by being imported.

// In stripe_provider.go
func init() {
    Register("stripe", func() PaymentProcessor {
        return &StripeProcessor{}
    })
}

// In main.go
import _ "myapp/providers/stripe" // The underscore import triggers init()

func main() {
    proc, err := GetProcessor("stripe")
    if err != nil {
        log.Fatal(err)
    }
    proc.Process(100.00)
}

Now, if I want to add a "Square" provider, I just create a new package, implement the interface, and add an init() function. I never have to touch the GetProcessor logic or the main function's core loop ever again.




📋 Practical Task

Build a Multi-Format Document Exporter Registry

Your task is to implement a plugin-style registry for a document exporting system. Instead of payment processors, you will build a system that can export data to different formats (e.g., PDF, JSON, CSV).

  • The Interface: Create a DocumentExporter interface with a method Export(data string) error.
  • The Registry: Implement a registry that uses factory functions (not singletons) to store and retrieve exporters by a string key.
  • The Implementations: Create at least two exporters (e.g., JSONExporter and CSVExporter) that print a message indicating the format being used.
  • The Automatic Setup: Use init() functions to register your exporters so that they are available as soon as the package is imported.
  • The Test: In your main function, retrieve an exporter by name and call its Export method. Handle the case where an invalid exporter name is requested.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.