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.gostarts the server. -
handlerscontains functions that process incoming HTTP requests. -
modelsdefines 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 inmain.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!
답글 남기기