Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 20

Configurable Data Backends

Recommended resources

Hello! Welcome back to your advanced Go course.

In the last lesson, you built a complete, concurrent-safe InMemoryStorage implementation. This was a crucial step, giving us two distinct modules (PostgresStorage and InMemoryStorage) that both satisfy our storage.Storage interface. The groundwork is laid.

Your Goal for Today

Learning Outcome: Swap between PostgreSQL and in-memory backends via configuration without changing application code.

Today, we'll realize the full benefit of our interface-driven design. You will modify the application's entrypoint to read a configuration value and, based on that value, decide which storage backend to instantiate and inject into our API server. This will make our application flexible and easier to develop and test, all without changing a single line of our core API logic. This concept is a cornerstone of clean, maintainable system design.

Let's begin.



1. The Composition Root: Wiring Your Application

The place where an application's components are instantiated and "wired" together is often called the composition root. In our project, this is the main function within cmd/api/main.go. This is the only place that should be aware of concrete implementations like *PostgresStorage or *InMemoryStorage. The rest of our application, namely the APIServer, only knows about the storage.Storage interface.

This architectural separation is often visualized using patterns like the Hexagonal (or Ports and Adapters) Architecture.

Hexagonal Architecture (Ports and Adapters)
In this diagram, our application's `Core` (services and business logic) is isolated. External concerns like the database or a web server connect through "Adapters". We have built a `PostgresStorage` adapter and an `InMemoryStorage` adapter, and now we will choose which one to plug in.

Currently, our main.go is hardcoded to use PostgreSQL. The first step is to make this choice dynamic.

To see a standard example of this wiring, let's look at a resource.

Repository Pattern in Golang: A Practical Guide - DEV Community

The article 'Repository Pattern in Golang: A Practical Guide' from DEV Community shows a typical main.go file that sets up a repository. It perfectly illustrates the decision point we want to automate.

In the provided text, find the 'Main Application' code block. Notice the line repo := repository.NewPostgresProductRepository(db) followed by the comment // Or use: repo := repository.NewInMemoryProductRepository(). Our goal is to make this switch happen based on configuration, not by commenting and uncommenting code.



2. Driving the Swap with Configuration

To automate the choice of backend, we need an external signal. Environment variables are a simple and standard way to provide configuration to Go applications.

We'll define an environment variable, STORAGE_TYPE, to control our selection.

  • If STORAGE_TYPE is set to "in_memory", we'll use InMemoryStorage.
  • If it's anything else (or not set at all), we'll default to using PostgresStorage.

Let's modify main.go to implement this logic.

Step 1: Declare the Storage Variable

First, declare a variable store of the interface type storage.Storage. This variable will hold whichever concrete implementation we decide to create.

// cmd/api/main.go

// ... imports
import (
    "log"
    "os" // Add the os package

    "github.com/your-username/your-project/internal/api"
    "github.com/your-username/your-project/internal/storage"
)

func main() {
    // ... (previous code for loading config, etc.)

    var store storage.Storage // Use the interface type
    // ...
}

Step 2: Read the Environment Variable and Select the Implementation

Now, we'll use os.Getenv() to read our environment variable and a switch statement to instantiate the correct type.

Update your main function to look like this. Note how we are replacing the previous hardcoded storage.NewPostgresStorage call.

// cmd/api/main.go

func main() {
    log.Println("Starting API server")

    var store storage.Storage
    var err error

    // Decide which storage backend to use
    switch os.Getenv("STORAGE_TYPE") {
    case "in_memory":
        log.Println("Using in-memory storage")
        store = storage.NewInMemoryStorage()
    default:
        log.Println("Using PostgreSQL storage")
        // NOTE: Assume you have a function or logic to get the DB connection string
        // For example, from another environment variable.
        connStr := os.Getenv("DATABASE_URL") 
        if connStr == "" {
            log.Fatal("DATABASE_URL environment variable is not set")
        }
        
        store, err = storage.NewPostgresStorage(connStr)
        if err != nil {
            log.Fatalf("Failed to connect to postgres: %v", err)
        }
    }
    
    // The rest of the code is unchanged!
    // It depends on the 'store' interface, not the concrete type.
    server := api.NewAPIServer(":8080", store)
    if err := server.Run(); err != nil {
        log.Fatalf("Failed to run server: %v", err)
    }
}

This is the heart of the pattern. The APIServer receives the store variable and has no idea which implementation it holds. It just calls the methods defined by the storage.Storage interface, and everything works.

This principle is fundamental to dependency injection and creating loosely coupled systems. The following video explains this exact concept.

Golang Dependency Injection Made EASY - The Repository Pattern

The video 'Golang Dependency Injection Made EASY' by samvcodes clearly explains this core idea. The service layer (our APIServer) only depends on the interface, allowing us to 'just change out' the concrete implementation during initialization.

Watch from 07:30 to 07:53. The key insight is that the business logic remains untouched; you simply provide a different repository object when you create the service. This is the principle we have just automated with our switch statement.



3. Running and Verifying

Now for the payoff. You can run your application with either backend using a simple command-line switch.

Instructions:

  1. Update your cmd/api/main.go with the code from the previous section.
  2. Open your terminal in the root of your project.
  3. Ensure your PostgreSQL database is running if you plan to test that backend.

To run with PostgreSQL (the default):
Make sure your DATABASE_URL is set correctly.




# Ensure your connection string is exported
export DATABASE_URL="postgres://user:password@localhost:5432/dbname?sslmode=disable"




# Run the application
go run ./cmd/api




# You should see the log output: "Using PostgreSQL storage"

Any data you create will be saved to the database.

To run with the in-memory store:
Set the STORAGE_TYPE environment variable before the go run command.




# Run the application with the in-memory store
STORAGE_TYPE=in_memory go run ./cmd/api




# You should see the log output: "Using in-memory storage"

Any data you create will be held in memory and will be lost when you stop the application.

You have now successfully made your storage layer swappable via configuration!

This final step is demonstrated in the same article we reviewed earlier.

Repository Pattern in Golang: A Practical Guide - DEV Community

Now that we've implemented the logic, let's see this pattern in a completed example. The same DEV Community article demonstrates how to run the application with different backends using an environment variable.

Find the 'Running the Project' section. Notice the commands DATABASE_URL=... go run main.go and USE_MEMORY=true go run main.go. This is the exact behavior we have now achieved in our own project.



4. An Alternative: The Functional Options Pattern

For a senior developer like yourself, it's worth knowing about more advanced configuration patterns. While our switch statement is perfectly fine, for services with many configurable parts (like loggers, caches, multiple repositories), the Functional Options Pattern offers a more scalable and idiomatic Go solution.

This pattern involves defining functions that modify a service's configuration. You then pass these functions to the service's constructor.

How To Implement Domain-Driven Design (DDD) in Golang

This is an optional but highly relevant exploration. For complex services, a simple switch statement can become unwieldy. The 'functional options pattern' is a powerful alternative. The article 'How To Implement Domain-Driven Design (DDD) in Golang' demonstrates it well.

You don't need to implement this now, but understanding the concept is valuable. Skim these sections to see the pattern: First code block in 'Repositories - The Repository Pattern': Notice the OrderConfiguration function type and the NewOrderService that accepts ...OrderConfiguration. First code block after that: See how WithCustomerRepository and WithMemoryCustomerRepository are functions that return a configuration function. Near the end, in 'Test_MongoTavern': Observe how NewOrderService is called with WithMongoCustomerRepository(...). Swapping backends is as simple as passing a different With... function. \nRecognizing this powerful pattern will serve you well in designing complex Go systems.



Conclusion

Fantastic work. You've now fully realized the power of the repository pattern and dependency injection. By abstracting the storage layer behind an interface, you've enabled the entire data persistence mechanism to be swapped out with a simple configuration change, leaving your core application logic completely untouched.

Key Takeaways:

  • The composition root (e.g., main.go) is the ideal place to instantiate and wire dependencies.
  • Environment variables are a standard way to drive configuration-based decisions in Go applications.
  • The combination of interfaces and dependency injection allows you to swap implementations (like PostgresStorage vs. InMemoryStorage) without modifying the client code (APIServer).
  • More advanced techniques like the Functional Options Pattern exist for managing complex service configurations.

Next Up

With our application structure now robust and flexible, the next step is to improve our error handling. In the following lesson, we will define and use custom error types. This will allow our API to return more meaningful and structured error responses, moving beyond generic fmt.Errorf messages.