Structuring Go Projects For Growth with Packages

작성자

카테고리:

← 피드로
DEV Community · Steve Omollo · 2026-07-31 개발(SW)

Previously, we built a complete CRUD API for managing tasks.

Although everything works, our entire application currently lives inside a single main.go file. This approach is fine for small examples, but as projects grow, a single file quickly becomes difficult to navigate and maintain.

In this article, we’ll reorganize our API into multiple packages, giving each part of the application a clear responsibility while still using Go’s standard library.

By the end, you’ll understand:

  • why Go projects are split into packages
  • how to organize code into folders
  • how to create your own packages
  • how to import your own code
  • how separation of concerns makes applications easier to maintain.

Step 1 — Our Current Project

Our project currently looks like this:

go-crud-api/
├─ go.mod
└─ main.go

Enter fullscreen mode Exit fullscreen mode

At this stage, every part of our application lives inside a single file. Our data model, HTTP handler, routing, and server startup code are still mixed together.

For a project with only a few features, this isn’t a problem. In fact, keeping in one file can make learning easier because all the code is in one place.

However, applications rarely stay this small.

Imagine adding:

  • user authentication
  • logging
  • middleware
  • request validation
  • database connections
  • configuration files

Very quickly, main.go would become hundreds of lines long, making the application harder to read and maintain.

Instead of putting everything into one file, Go encourages us to group related code together using packages.

Step 2 — Create the Project Structure

We’ll reorganize the project into separate folders.

Our project will now look like this:

go-crud-api/
├─ go.mod
├─ main.go
├─ handlers/
│  └─ tasks.go
└─ models/
   └─ task.go

Enter fullscreen mode Exit fullscreen mode

Rather than one file doing everything, each folder now has a specific purpose.

  • main.go starts the server.
  • handlers contains functions that process incoming HTTP requests.
  • models defines the application’s data structures.

This idea is known as separation of concerns.

Instead of one file handling every responsibility, each package focuses on one job.

As your application grows, this organization makes it much easier to locate code, fix bugs, and add new features.

Step 3 — Move the Task Struct

Create a folder named models, then create a file called task.go.

Add the following code:

package models

type Task struct {
    ID    int    `json:"id"`
    Title string `json:"title"`
}

Enter fullscreen mode Exit fullscreen mode

Let’s break this down.

The first line is:

package models

Enter fullscreen mode Exit fullscreen mode

Every Go source file belongs to a package.

A package is simply a way of grouping related code together.

Think of a package as a folder that contains code with a common purpose.

In our project, the models package will contain the structures that represent our application’s data.

In this case, that data is our Task struct.

Moving the struct into its own package makes it reusable throughout the application without duplicating code.

Later, if we add User or Project struct, they can also live inside the models package.

Step 4 — Create the Handler

Now we’ll move the CRUD logic from main.go into the handlers package.

Most of the code is identical to what we wrote in the previous article. The main difference is that now it lives in its own package and uses the Task struct from the models package.

Create a folder named handlers, then add a file called tasks.go.

Add the following code:

package handlers

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

    "go-crud-api/models"
)

var tasks []models.Task

func TaskHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")

    switch r.Method {

    case http.MethodGet:
        json.NewEncoder(w).Encode(tasks)

    case http.MethodPost:
        var task models.Task

        err := json.NewDecoder(r.Body).Decode(&task)
        if err != nil {
            http.Error(w, "Invalid JSON", http.StatusBadRequest)
            return
        }

        tasks = append(tasks, task)
        json.NewEncoder(w).Encode(task)

    case http.MethodPut:
        var updatedTask models.Task

        err := json.NewDecoder(r.Body).Decode(&updatedTask)
        if err != nil {
            http.Error(w, "Invalid JSON", http.StatusBadRequest)
            return
        }

        for i, task := range tasks {
            if task.ID == updatedTask.ID {
                tasks[i].Title = updatedTask.Title
                json.NewEncoder(w).Encode(tasks[i])
                return
            }
        }

        http.Error(w, "Task not found", http.StatusNotFound)

    case http.MethodDelete:
        var taskToDelete models.Task

        err := json.NewDecoder(r.Body).Decode(&taskToDelete)
        if err != nil {
            http.Error(w, "Invalid JSON", http.StatusBadRequest)
            return
        }

        for i, task := range tasks {
            if task.ID == taskToDelete.ID {
                tasks = append(tasks[:i], tasks[i+1:]...)
                w.Write([]byte("Task deleted"))
                return
            }
        }

        http.Error(w, "Task not found", http.StatusNotFound)

    default:
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
    }
}

Enter fullscreen mode Exit fullscreen mode

This file now contains the same CRUD logic from the previous article, but it has been moved into its own package and updated to use the models.Task type.

You might be wondering why the tasks slice wasn’t moved into the models package along with the Task struct.

The models package is responsible for defining the shape of our data, while the handlers package is responsible for processing HTTP requests. Since we’re still storing tasks in memory for simplicity, keeping the slice in the handler avoids introducing another package before it’s needed.

In a larger application, this data would typically be managed by a database or a dedicated storage layer.

What’s different?

You may notice a few differences compared to the previous article.

First, the package declaration is now:

package handlers

Enter fullscreen mode Exit fullscreen mode

instead of:

package main

Enter fullscreen mode Exit fullscreen mode

because this file belongs to the handlers package.

Second, we import our own package:

"go-crud-api/models"

Enter fullscreen mode Exit fullscreen mode

This allows us to use the Task struct that now lives inside the models package.

Finally, the handler is named TaskHandler instead of taskHandler.

Because it begins with a capital letter, it is exported and can be accessed from main.go.

If we left the function names as taskHandler, Go would report an error because functions that begin with a lowercase letter are only visible within the same package.

Understanding Import Paths

You might be wondering where this import path comes from:

"go-crud-api/models"

Enter fullscreen mode Exit fullscreen mode

The first part, go-crud-api, is the module name we created earlier when we ran:

go mod init go-crud-api

Enter fullscreen mode Exit fullscreen mode

Go uses the module name as the base for importing packages within the same project.

If your module had a different name, such as:

go mod init task-manager

Enter fullscreen mode Exit fullscreen mode

the import would become:

"task-manager/models"

Enter fullscreen mode Exit fullscreen mode

This allows the handlers package to access exported identifiers from the models package.

This is why it’s important that the import path matches the module name in your go.mod file.

Step 5 — Update main.go

Now that the application has been split into packages, main.go becomes much smaller.

package main

import (
    "net/http"

    "go-crud-api/handlers"
)

func main() {
    http.HandleFunc("/tasks", handlers.TaskHandler)

    http.ListenAndServe(":8080", nil)
}

Enter fullscreen mode Exit fullscreen mode

Since TaskHandler now lives in another package, we must import thehandlerspackage before we can use it. Once imported, we access the exported functions using the package name followed by a dot, such ashandlers.TaskHandler`.

Notice how little code remains.

Instead of containing all the CRUD logic, main.go now has a single responsibility: starting the application.

This makes the file much easier to read.

Whenever we need to modify how tasks are handled, we know exactly where to look: the handlers package.

Keeping each package focused on one responsibility makes applications easier to understand as they grow.

Understanding Exported Names

You may have noticed that our handler function is now called:

go
TaskHandler

instead of:

go
taskHandler

This change is important.

In Go, any name that begins with a capital letter is exported.

Exported identifiers can be accessed from other packages.

Names beginning with a lowercase letter remain private to the package where they are declared.

For example:

go
func TaskHandler(...) {}

can be used from main.go, while:

go
func taskHandler(...) {}

cannot.

This convention is one of the simplest yet most distinctive features of Go, and you’ll use it throughout the standard library.

What Changed?

Although our API behaves exactly as before, its internal structure is much cleaner.

Here’s what changed:

Before After Everything lived in main.go Code is split into packages One file handled everything Each package has one responsibility Difficult to grow Easier to maintain Harder to navigate Easier to understand

We haven’t added any new features yet—but we’ve made our project much easier to extend.

As applications grow, good organization becomes just as important as writing correct code.

Step 6 — Run the Application

bash
go run .

Notice that we use go run . instead of go run main.go.

Now test the API just as you did in the previous article.

  • Create a task.
  • Retrieve the list of tasks.
  • Update a task.
  • Delete a task.

You’ll notice that the API behaves exactly as before. The only difference is that the code is now organized into packages, making it easier to maintain as the project grows.

Although we reorganized the code into packages, the application’s behavior hasn’t changed. We’ve simply made the project easier to understand and maintain.

Once a project spans multiple files and packages, go run . becomes the more natural command because it builds the entire module.

Thanks for reading!

Happy coding!

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다