Skip to main content

Welcome to a Grasp lesson

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

Lesson 18

Integrating PostgreSQL with Dependency Injection

Recommended resources

Hello! Let's dive into our next lesson.

In our last session, we built a robust configuration system using Viper. Our main function now loads settings from a file and environment variables, and it correctly initializes a PostgresStorage object with the configured database connection string. However, our API handlers currently have no way to access this storage object. They are isolated and cannot perform any database operations.

Your Goal for Today

Learning Outcome: Wire the PostgreSQL repository into the API handlers using dependency injection.

Today, we will connect the pieces. You will learn how to make the PostgresStorage instance available to your API handlers using a powerful and fundamental software design pattern: Dependency Injection (DI). This technique is central to building modular, testable, and maintainable systems—a cornerstone of the advanced Go practices you're aiming to master.

We'll refactor our server to hold application-level dependencies and pass them down to the handlers that need them. By doing this, we'll not only make our handlers functional but also decouple them from the specific database implementation, setting the stage for easily swappable storage backends.



1. The Challenge: Giving Handlers Access to Dependencies

How can our handleCreateAccount function get access to the db object we created in main()?

A simple, but problematic, approach would be to use a global variable. You could define a global var DB *storage.PostgresStorage and assign to it in main(). While this works for trivial applications, it introduces significant problems in larger systems:

  • Hidden Dependencies: It's not clear what a function depends on just from its signature.
  • Testability: Tests can interfere with each other by modifying the same global state. Mocking the dependency becomes difficult.
  • Concurrency: Global variables shared between goroutines can easily lead to race conditions if not managed carefully with mutexes.

Given your experience in system design, you are likely familiar with the pitfalls of global state. The professional solution is Dependency Injection.


2. Understanding Dependency Injection (DI)

Dependency Injection is a form of Inversion of Control (IoC). Instead of a component creating its own dependencies, the dependencies are "injected" from an external source (usually at application startup).

Dependency Injection Refactoring Example
This diagram shows the core idea. Before DI, `ClassA` is tightly coupled to concrete `ServiceA`. After DI, a `Builder` (our `main` function) creates `ServiceA` and injects it into `ClassA`, which now only depends on the `IServiceA` interface. This decouples the components.

In Go, we don't need complex frameworks for DI. We can achieve it idiomatically using structs and interfaces. The most common pattern for a web server is to create a central struct that holds all shared dependencies. Handlers are then implemented as methods on this struct, giving them access to the dependencies.

Let's explore this pattern.

Organising database access in Go - Alex Edwards

The article 'Organising database access in Go' by Alex Edwards provides an excellent, practical overview of different patterns. We'll focus on his explanation of the dependency injection approach, which is considered a best practice.

Please read section '2. Dependency injection'. Focus on how an Env struct is created to hold the database connection and how the booksIndex handler is defined as a method on this struct, allowing it to access env.db. This is the exact pattern we will implement.

This Env struct (which we will call APIServer) acts as a container for our application's dependencies. By defining handlers as methods, we provide them with a clean, type-safe way to access the store, logger, and any other shared resources.



3. Implementing Dependency Injection in Our Project

Let's apply this pattern to our API. We'll create an APIServer struct that holds our storage.Storage interface and wire it up in main.

Step 1: Define the APIServer Struct

First, we'll refactor our internal/server/server.go file. Instead of a function that just returns an *http.Server, we'll create a struct to represent our API server and its dependencies.

In internal/server/server.go, update the code as follows:

// internal/server/server.go
package server

import (
	"encoding/json"
	"log"
	"net/http"

	"your_module_path/internal/storage" // Adjust import path
)

// APIServer holds the dependencies for our API server.
type APIServer struct {
	listenAddr string
	store      storage.Storage
}

// NewAPIServer creates a new APIServer instance.
func NewAPIServer(listenAddr string, store storage.Storage) *APIServer {
	return &APIServer{
		listenAddr: listenAddr,
		store:      store,
	}
}

// Start runs the HTTP server.
func (s *APIServer) Start() error {
	router := http.NewServeMux()

	// Register handlers
	router.HandleFunc("POST /accounts", s.handleCreateAccount)
	// Add other handlers here, e.g., router.HandleFunc("GET /accounts/{id}", s.handleGetAccount)

	log.Printf("server starting on %s", s.listenAddr)

	server := &http.Server{
		Addr:    s.listenAddr,
		Handler: router,
	}

	return server.ListenAndServe()
}

// Convert handleCreateAccount into a method on APIServer
func (s *APIServer) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
	// Our existing handler logic...
	// ... but now we can access the store via `s.store`
	// (Implementation details will be added in the next step)
}

// Our helper functions remain the same
func writeJSON(w http.ResponseWriter, status int, v any) error {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	return json.NewEncoder(w).Encode(v)
}
// ... other helpers ...

Key Changes:

  1. We created an APIServer struct to hold the server's address and, crucially, the storage.Storage dependency.
  2. The NewAPIServer function acts as our constructor, where the dependencies are injected.
  3. The Start method now sets up the router and starts the server. Notice how it registers s.handleCreateAccount—a method, not a standalone function.

Step 2: Update the Handler to Use the Injected Dependency

Now, let's complete the handleCreateAccount method. Because it's a method on APIServer, it can access the storage layer through the receiver s.

Replace the placeholder handleCreateAccount with the full implementation:

// internal/server/server.go

// ... (APIServer struct, NewAPIServer, and Start method are above) ...

func (s *APIServer) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
	// 1. Parse the incoming request
	var req storage.CreateAccountRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request payload"})
		return
	}
	defer r.Body.Close()

	// 2. Create a new Account from the request
	account, err := storage.NewAccount(req.FirstName, req.LastName, req.Password)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
		return
	}
	
	// 3. Use the injected storage dependency to persist the account
	if err := s.store.CreateAccount(account); err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create account"})
		return
	}

	// 4. Return the created account as JSON
	writeJSON(w, http.StatusCreated, account)
}

The logic is the same as before, but the crucial line s.store.CreateAccount(account) now works because s.store was injected when the APIServer was created.

Step 3: Wire Everything in main.go

Finally, let's update cmd/server/main.go to use our new APIServer. This is where the actual injection happens.

// cmd/server/main.go
package main

import (
	"fmt"
	"log"

	"your_module_path/internal/config"
	"your_module_path/internal/server"
	"your_module_path/internal/storage"
)

func main() {
	// --- Load Configuration ---
	cfg, err := config.LoadConfig(".")
	if err != nil {
		log.Fatalf("failed to load configuration: %v", err)
	}

	// --- Initialize Storage Dependency ---
	dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
		cfg.Database.Host, cfg.Database.Port, cfg.Database.User,
		cfg.Database.Password, cfg.Database.Name, cfg.Database.SSLMode,
	)
	db, err := storage.NewPostgresStorage(dsn)
	if err != nil {
		log.Fatalf("failed to connect to database: %v", err)
	}
	// Note: We are not deferring db.Close() here because the server runs indefinitely.
	// Proper shutdown handling will be covered in a future lesson.

	// --- Inject Dependencies and Initialize Server ---
	apiServer := server.NewAPIServer(fmt.Sprintf(":%d", cfg.Server.Port), db)

	// --- Start Server ---
	if err := apiServer.Start(); err != nil {
		log.Fatalf("failed to start server: %v", err)
	}
}

Look at the line server.NewAPIServer(..., db). This is the moment of injection. We are passing the concrete *storage.PostgresStorage instance to a function that expects the storage.Storage interface. The Go compiler ensures this is valid because *PostgresStorage implements the interface.

This entire process of creating dependencies and passing them into the layers that need them is shown very clearly in the following video.

Golang Microservices: Repository Pattern, Dependency Injection and Services.

The video 'Golang Microservices: Repository Pattern, Dependency Injection and Services' from the Mario Carrion channel is an excellent explanation of this exact topic. Pay close attention to how the concrete datastore is created and then passed into the service constructor.

Please watch these two key segments: Service receiving the repository (04:21 - 05:18): Focus on how the Service struct is instantiated with a repository that is defined as an interface type. This illustrates why depending on interfaces is so powerful. Wiring in main (06:08 - 06:46): This shows the final wiring. A concrete datastore (postgres.Task) is created and then passed as an argument to the service constructor. This is the 'injection' step in action.



Conclusion

Congratulations! You have successfully wired your application's layers together using dependency injection. Your API handlers are no longer isolated; they have access to the storage layer and can now perform real work.

Key Takeaways:

  • Dependency Injection (DI) is a pattern where a component's dependencies are provided to it from an external source, rather than being created internally.
  • In Go, a common and idiomatic way to implement DI for an HTTP server is to create a central struct (e.g., APIServer) to hold dependencies.
  • Handlers are defined as methods on this struct, giving them type-safe access to the dependencies via the method receiver (e.g., s.store).
  • The main function becomes the "assembler" or "wiring" point, where concrete implementations are created and injected into the application layers.
  • Most importantly, our APIServer depends on the storage.Storage interface, not the concrete *storage.PostgresStorage struct.

Next Up

The power of depending on interfaces will become crystal clear in our next lesson. We will create an in-memory storage backend that also satisfies the storage.Storage interface. You'll see how, with a simple change in our configuration and main function, we can swap out a full PostgreSQL database for a simple in-memory map, without changing a single line of our API handler logic. This is a game-changer for fast, isolated testing.