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:
- Improved readability
- Easier maintenance
- Better testability
- Clearer separation of concerns
- 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
- Separation of Concerns: Each package has a specific responsibility.
- Dependency Injection: We’re passing dependencies (like the Firestore client) instead of using global variables.
- Testability: It’s easier to write unit tests for individual components.
- 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.