Structuring a Modular Go Fiber Application with Firestore

Introduction

In our previous post, we built a Go Fiber API with Google Firestore. While functional, the code was all in a single file, which isn’t ideal for larger projects. In this post, we’ll refactor our application into a more modular and maintainable structure.

Why Modular Structure Matters

A modular structure offers several benefits:

  1. Improved readability
  2. Easier maintenance
  3. Better testability
  4. Clearer separation of concerns
  5. Easier collaboration in team settings

Proposed Directory Structure

Let’s start with a proposed directory structure:

project_root/
│
├── cmd/
│   └── api/
│       └── main.go
│
├── internal/
│   ├── config/
│   │   └── config.go
│   ├── handlers/
│   │   └── user_handler.go
│   ├── models/
│   │   └── user.go
│   ├── repository/
│   │   └── firestore/
│   │       └── user_repository.go
│   └── routes/
│       └── routes.go
│
├── pkg/
│   └── database/
│       └── firestore.go
│
└── go.mod

Breaking Down the Structure

cmd/api/main.go

This is the entry point of our application. It will initialize the Fiber app, set up the database connection, and start the server.

package main

import (
    "log"

    "github.com/gofiber/fiber/v2"
    "your_module/internal/config"
    "your_module/internal/routes"
    "your_module/pkg/database"
)

func main() {
    cfg := config.Load()
    db, err := database.NewFirestoreClient(cfg.FirestoreCredentialsPath)
    if err != nil {
        log.Fatalf("Failed to connect to Firestore: %v", err)
    }
    defer db.Close()

    app := fiber.New()
    routes.SetupRoutes(app, db)

    log.Fatal(app.Listen(cfg.ServerAddress))
}

internal/config/config.go

This file will handle loading configuration, potentially from environment variables or a config file.

package config

import "os"

type Config struct {
    ServerAddress            string
    FirestoreCredentialsPath string
}

func Load() *Config {
    return &Config{
        ServerAddress:            os.Getenv("SERVER_ADDRESS"),
        FirestoreCredentialsPath: os.Getenv("FIRESTORE_CREDENTIALS_PATH"),
    }
}

internal/models/user.go

This file defines our User model.

package models

type User struct {
    ID    string `json:"id" firestore:"-"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

internal/repository/firestore/user_repository.go

This file handles database operations for the User model.

package firestore

import (
    "context"

    "cloud.google.com/go/firestore"
    "your_module/internal/models"
)

type UserRepository struct {
    client *firestore.Client
}

func NewUserRepository(client *firestore.Client) *UserRepository {
    return &UserRepository{client: client}
}

func (r *UserRepository) Create(user *models.User) error {
    // Implementation
}

func (r *UserRepository) Get(id string) (*models.User, error) {
    // Implementation
}

// Add Update and Delete methods

internal/handlers/user_handler.go

This file contains our HTTP handlers.

package handlers

import (
    "github.com/gofiber/fiber/v2"
    "your_module/internal/models"
    "your_module/internal/repository/firestore"
)

type UserHandler struct {
    repo *firestore.UserRepository
}

func NewUserHandler(repo *firestore.UserRepository) *UserHandler {
    return &UserHandler{repo: repo}
}

func (h *UserHandler) Create(c *fiber.Ctx) error {
    // Implementation
}

func (h *UserHandler) Get(c *fiber.Ctx) error {
    // Implementation
}

// Add Update and Delete methods

internal/routes/routes.go

This file sets up our routes.

package routes

import (
    "github.com/gofiber/fiber/v2"
    "cloud.google.com/go/firestore"
    "your_module/internal/handlers"
    "your_module/internal/repository/firestore"
)

func SetupRoutes(app *fiber.App, client *firestore.Client) {
    userRepo := firestore.NewUserRepository(client)
    userHandler := handlers.NewUserHandler(userRepo)

    api := app.Group("/api")
    users := api.Group("/users")

    users.Post("/", userHandler.Create)
    users.Get("/:id", userHandler.Get)
    // Add routes for Update and Delete
}

pkg/database/firestore.go

This file handles the Firestore client initialization.

package database

import (
    "context"

    "cloud.google.com/go/firestore"
    firebase "firebase.google.com/go"
    "google.golang.org/api/option"
)

func NewFirestoreClient(credentialsPath string) (*firestore.Client, error) {
    ctx := context.Background()
    sa := option.WithCredentialsFile(credentialsPath)
    app, err := firebase.NewApp(ctx, nil, sa)
    if err != nil {
        return nil, err
    }

    client, err := app.Firestore(ctx)
    if err != nil {
        return nil, err
    }

    return client, nil
}

Benefits of This Structure

  1. Separation of Concerns: Each package has a specific responsibility.
  2. Dependency Injection: We’re passing dependencies (like the Firestore client) instead of using global variables.
  3. Testability: It’s easier to write unit tests for individual components.
  4. Scalability: As the project grows, it’s clear where new files should be placed.

Conclusion

By restructuring our Go Fiber application with Firestore, we’ve created a more modular and maintainable codebase. This structure provides a solid foundation for building larger, more complex applications.

Remember, the exact structure can vary based on project needs, but the principles of separation of concerns and dependency injection should guide your decisions.

Canonical Example

Here’s a simplified example of how our main.go might look with this new structure:

package main

import (
    "log"

    "github.com/gofiber/fiber/v2"
    "your_module/internal/config"
    "your_module/internal/routes"
    "your_module/pkg/database"
)

func main() {
    cfg := config.Load()
    
    db, err := database.NewFirestoreClient(cfg.FirestoreCredentialsPath)
    if err != nil {
        log.Fatalf("Failed to connect to Firestore: %v", err)
    }
    defer db.Close()

    app := fiber.New()
    
    routes.SetupRoutes(app, db)

    log.Printf("Server starting on %s", cfg.ServerAddress)
    log.Fatal(app.Listen(cfg.ServerAddress))
}

This main function is now much cleaner and focused solely on application setup and startup. The details of routing, handling requests, and interacting with the database are all handled in their respective packages.