Building a Go Server with SOLID Principles: A Simple Guide

Hey there, Gopher!

Ever wondered how to craft a clean, maintainable server in Go? You're not alone! In this article, I'll walk you through building a Go server while adhering to SOLID principles. By the end of this journey, you'll understand how to create a robust, scalable application that’s easy to extend and maintain. Ready? Let’s dive in!

What Are SOLID Principles?

Before we jump into code, let's quickly recap what SOLID principles are:

  1. Single Responsibility Principle (SRP): A module should have one reason to change.

  2. Open/Closed Principle (OCP): Entities should be open for extension but closed for modification.

  3. Liskov Substitution Principle (LSP): Subtypes must be substitutable for their base types without altering the correctness of the program.

  4. Interface Segregation Principle (ISP): Clients should not be forced to depend on interfaces they don’t use.

  5. Dependency Inversion Principle (DIP): High-level modules should depend on abstractions, not on concrete implementations.

With these principles in mind, let’s build a Go server!

Project Structure

Here’s a simple layout for our project:

goCopy code/server
    ├── main.go
    ├── config
    │   └── config.go
    ├── handlers
    │   └── user_handler.go
    ├── models
    │   └── user.go
    ├── services
    │   └── user_service.go
    ├── repositories
    │   └── user_repository.go
    ├── routes
    │   └── routes.go
    └── utils
        └── error.go

This structure keeps our code organized, with clear responsibilities for each component.

Single Responsibility Principle (SRP)

Each file and function should have a single responsibility. For instance, user_handler.go handles HTTP requests related to users, while user_service.go contains business logic. This separation of concerns helps in keeping our code clean and easy to manage.

Example: user_handler.go

import (
    "encoding/json"
    "net/http"
    "server/models"
    "server/services"
    "strconv"
)

type UserHandler struct {
    service services.UserService
}

func NewUserHandler(service services.UserService) *UserHandler {
    return &UserHandler{service: service}
}

func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
    var user models.User
    if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    if err := h.service.CreateUser(user); err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    w.WriteHeader(http.StatusCreated)
}

func (h *UserHandler) GetUserByID(w http.ResponseWriter, r *http.Request) {
    idStr := r.URL.Query().Get("id")
    id, err := strconv.Atoi(idStr)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    user, err := h.service.GetUserByID(id)
    if err != nil {
        http.Error(w, err.Error(), http.StatusNotFound)
        return
    }
    json.NewEncoder(w).Encode(user)
}

Open/Closed Principle (OCP)

We want our server to be easy to extend without modifying existing code. By using interfaces, we can add new features or change implementations without touching the core logic.

Example: user_repository.go

import "server/models"

type UserRepository interface {
    Save(user models.User) error
    FindByID(id int) (models.User, error)
}

type InMemoryUserRepository struct {
    data map[int]models.User
}

func NewInMemoryUserRepository() *InMemoryUserRepository {
    return &InMemoryUserRepository{data: make(map[int]models.User)}
}

func (repo *InMemoryUserRepository) Save(user models.User) error {
    repo.data[user.ID] = user
    return nil
}

func (repo *InMemoryUserRepository) FindByID(id int) (models.User, error) {
    user, exists := repo.data[id]
    if !exists {
        return models.User{}, fmt.Errorf("user not found")
    }
    return user, nil
}

Liskov Substitution Principle (LSP)

Our implementations should be interchangeable without affecting the behavior of the system. For this, any implementation of an interface should be usable in place of the interface.

Example: user_service.go

import (
    "server/models"
    "server/repositories"
)

type UserService interface {
    CreateUser(user models.User) error
    GetUserByID(id int) (models.User, error)
}

type userService struct {
    repo repositories.UserRepository
}

func NewUserService(repo repositories.UserRepository) UserService {
    return &userService{repo: repo}
}

func (s *userService) CreateUser(user models.User) error {
    return s.repo.Save(user)
}

func (s *userService) GetUserByID(id int) (models.User, error) {
    return s.repo.FindByID(id)
}

Interface Segregation Principle (ISP)

Design your interfaces to be focused and specific. This prevents clients from being forced to implement methods they don’t need.

Example: user_repository.go

// UserRepository interface is focused on user-related operations
type UserRepository interface {
    Save(user models.User) error
    FindByID(id int) (models.User, error)
}

Dependency Inversion Principle (DIP)

High-level modules should depend on abstractions rather than concrete implementations. This makes our code more flexible and easier to test.

Example: routes.go


import (
    "net/http"
    "server/handlers"
    "server/repositories"
    "server/services"
    "github.com/gorilla/mux"
)

func SetupRouter(cfg *config.Config) *mux.Router {
    router := mux.NewRouter()

    repo := repositories.NewInMemoryUserRepository()
    userService := services.NewUserService(repo)
    userHandler := handlers.NewUserHandler(userService)

    router.HandleFunc("/users", userHandler.CreateUser).Methods("POST")
    router.HandleFunc("/users", userHandler.GetUserByID).Methods("GET")

    return router
}

Wrapping Up

And there you have it! A Go server built with SOLID principles, ensuring it’s clean, maintainable, and ready for future extensions. By adhering to these principles, you're not just writing code—you're crafting a system that’s robust and adaptable.