From Zero to Go Hero: Learn Go Programming with Me - Part 4
Building CRUD API

Search for a command to run...
Building CRUD API

No comments yet. Be the first to comment.
Lessons from Code, Content, and Consistency

Reliability basics: Outbox pattern + why it matters

Introducing Kafka/Redpanda + move to event-driven workflow

Connect Orders ↔ Inventory (first working service-to-service flow)

Inventory service + gRPC + proto contracts

Heyy Gophers!!! Welcome to the Part 4 of Learning Go with Me. Thanks for coming alone to till here. I hope you are learning and enjoying this series.
Till now we almost covered the fundamentals of Go and now we are ready to write out the first API.
It’s gonna be exciting as we will learn a lot of new things which will help you in your first programming career
Let’s get started without wasting any more time.
crud-api/
│
├── cmd/
│ └── main/
│ └── main.go # Entry point for the application
│
├── config/
│ └── config.go # Environment variable management
│
├── internal/
│ ├── routes/
│ │ └── routes.go # API routes definition
│ ├── books/
│ │ ├── handler.go # HTTP handlers for book operations
│ │ ├── model.go # Book database model and queries
│ │ └── service.go # Business logic for books
│ ├── database/
│ │ └── db.go # Database connection logic
│
├── .env # Environment variables
├── go.mod # Go module dependencies
└── .gitignore # Ignored files
go get github.com/go-chi/chi/v5
go get github.com/go-sql-driver/mysql
go get github.com/joho/godotenvfg
// .env
PORT=8080
DB_USER=root
DB_PASSWORD=password
DB_HOST=127.0.0.1:3306
DB_NAME=crud_api
Now let’s go step by step. As we already have the .env file ready for us, why not create a function to load it inside our project?
Before that let’s see the main.go file
// main.go
package main
func main() {
// TODO: Load .env file
// TODO: Setup database connection
// TODO: Setup router
// TODO: Run server
}
.env VariablesTo load values inside our .go files we are using godotenv package. This package helps us get environmental values inside our file.
We implement this function inside config.go file. So head over to config.go and let’s write LoadEnv function. But before that let’s import a few packages
// config/config.go
package config
import (
"log"
"os"
"github.com/joho/godotenv"
)
log, os and godotenv packages here.fmt and log PackagesIn Go we mostly use these two packages to print out values and logs in the terminal. At first, these two may look similar but they both serve different purposes.
log is specifically designed for logging messages. This means we can get more information like time stamping and error reporting with this. So we mostly use it during application runtime like debugging, informational logs, and error logs.
log.Fatal(Exists the program after logging) and log.Panic (Logs and then panic)Whereas fmt is used for general-purpose printing text. It is commonly used for printing in a console with formatted strings. So we use it mostly when we are printing user-facing messages.
fmt.Println("Hello World") // Hello World
fmt.Printf("Hello %s", "World") // Hello World
log.Fatalf("This is a fatal error.") // This is a fatal error. (After this program stops)
log.Panic("This is a panic error.") // This is a panic error. (After this program stops)
More on fmt visit: https://pkg.go.dev/fmt
More on log visit: https://pkg.go.dev/log
os PackageThe os package in Go is used for tasks related to the operating system. For example accessing the files, and environment variable.
In our example, we are focused on accessing the .env file and it has Getenv() a function that receives the values of environment variables named by key
LoadEnv() functionimport (...)
func LoadEnv() {
err := godotenv.Load("../.env")
if err != nil {
log.Fatal("Error loading .env file")
}
}
We get Load() function inside godotenv package. Load will read the .env file and load them into ENV for this process. Notice that you pass the path of your .env file.
This function returns an error so we are collecting it inside err variable and then checking if the err has anything. If the err is not nil which means something went wrong. If so, we need to show the error message and exit the program. Because if the env file is not loaded then there is no point in starting the program.
Now to get a specific value from the env file we need to make one function that will get us the value of the asked key from the env file
package config
import (...)
func LoadEnv() {
...
}
func GetEnv(key, fallback string) string {
if value, ok := os.LookupEnv(key); ok {
return value
}
return fallback
}
We created the GetEnv function, which takes key and fallback strings as parameters and returns a string.
The key is the string for which we need the value in our code. For example, inside our .env file, we have different values. If we need only DB_USER, we pass this key to the function, and it returns the related value.
We also accept a fallback string, so if the requested value isn’t found, we use the default fallback value.
Here, we are using the os package, which includes the LookupEnv function. This function takes a key as an argument and returns two things: a string and a bool. If the value is found, ok will be true; otherwise, it will be false. By checking ok, we can determine if a value was returned and decide whether to return the fallback value.
Let’s check if it is working or not
package main
import (
"fmt"
"github.com/red-star25/crud-api/config"
)
func main() {
config.LoadEnv()
fmt.Println(config.GetEnvValue("DB_USER", "root"))
// TODO: Setup database connection
// TODO: Setup router
// TODO: Run server
}
Output:
root
As you know we are using MySQL for storing our application’s data. We need to first set up the connection and create an instance of the SQL DB.
Head over to db.go file and let’s first import all the required packages
package database
import (
"database/sql"
"fmt"
"log"
"os"
_ "github.com/go-sql-driver/mysql"
)
We have seen the usage of fmt, log and os. Let’s see what are the remaining ones.
database/sql: This is a built-in library for SQL database interactions.
_ "github.com/go-sql-driver/mysql": Imports the MySQL driver as a side effect to register it with database/sql. Here _ (underscore) represent that this import is used as a side effect. Which means this package is not directly used.
var DB *sql.DB
Here type DB is a pointer to sql.DB an object. We are making it globally accessible so that anyone can reuse it without creating it again and again on each query
You must be wondering Why it is a type of pointer.
So, If DB were a simple variable (not a pointer), any changes made to it inside a function, wouldn't affect the global variable itself. Instead, Go would create a copy of the value, and the global DB would remain unchanged.
By using a pointer, it updates the global DB directly. This makes the database connection immediately available to all parts of the application.
Connect that will be responsible for opening the database connection and making a connection.func Connect() {
dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s",
config.GetEnvValue("DB_USER", "root"),
config.GetEnvValue("DB_PASSWORD", ""),
config.GetEnvValue("DB_HOST", "localhost"),
config.GetEnvValue("DB_NAME", "crud_api"),
)
}
Here we have created a variable dsn(Data Source Name) which is a type of string. So before we connect to MySQL we need to have a URL to which we want to connect.
So here we have used a formatted string from fmt package. Here function Sprintf returns a formatted string without printing it to the console. %s is a placeholder for string values here.
And we are getting all the values from our .env file using config’s GetEnvValue function.
Example dsn
username:password@tcp(localhost)/mydatabase
Now that our data source URL is ready, we are ready to open our SQL connection.
To do that mysql provides Open() function which takes
driverName (which is nothing but a database name, ex: MySQL, MongoDB, Postgres, etc). You can provide any driver here and open the connection and
dataSourceName is a URL of the database that we just created above.
var err error
DB, err = sql.Open("mysql", dsn)
if err != nil {
log.Fatalf("Error connecting to database: %v", err)
}
This function returns two things: db object and err.
So what we are doing here is, we are assigning the returned SQL db object to our globally defined DB.
And then we check if there is any error occurred or not.
You will see this type of error handling thoughtout any Go project. This is a convention in Go. It might seems little overdoing but it is a good practice that we are handling error just after calling function.
Also note that there is no
trycatchorfinallykeywords in Go. You can read why they are using this type of error handling in this article : https://go.dev/doc/faq#exceptions
Ping function that verifies if the database connection is still alive, and it also establishes the connection if necessary.if err := DB.Ping(); err != nil {
log.Fatalf("Error verifying database connection: %v", err)
}
db.go codepackage database
import (
"database/sql"
"fmt"
"log"
_ "github.com/go-sql-driver/mysql"
"github.com/red-star25/crud-api/config"
)
var DB *sql.DB
func Connect() {
dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s",
config.GetEnvValue("DB_USER", "root"),
config.GetEnvValue("DB_PASSWORD", ""),
config.GetEnvValue("DB_HOST", "localhost"),
config.GetEnvValue("DB_NAME", "crud_api"),
)
var err error
DB, err = sql.Open("mysql", dsn)
if err != nil {
log.Fatalf("Error connecting to database: %v", err)
}
if err := DB.Ping(); err != nil {
log.Fatalf("Error verifying database connection: %v", err)
}
log.Println("Database connected successfully!")
}
Connect function inside main.gopackage main
import (
"github.com/red-star25/crud-api/config"
"github.com/red-star25/crud-api/database"
)
func main() {
config.LoadEnv()
database.Connect()
// TODO: Setup router
// TODO: Run server
}
If everything goes right then you will see this message after you run the program.

Make sure you have MySQL up and running on your computer.
Book modelstructIn Go, the term "model" refers to a structured representation of data used within an application, typically to map data between a program and an external data source, such as a database or an API. Models are often implemented as
structsin Go, which are a way to define and organize related data fields.
// internal/books/model.go
package books
type Book struct {
ID int
Title string
Author string
Year int
}
Here we have four fields inside our Book struct. ID, Title, Author, Year.
That’s easy, right? Yes. But there is one more thing which we need to do. So as we know that when we deal with APIs, we use JSON to serialize and deserialize the data that we are getting from the user and data we are sending to the user.
And generally, they are represented in lowercase like id, name, author, year like that. But if you see our Book struct we have the first letter capital for each field. And that is intentional. Because we need those struct’s fields outside to use. And to make it globally accessible we capitalize the first letter of any variable.
So to resolve this issue, we use what we call in go “ struct tags “. In other programming languages, we refer to this as “ annotations “.
This helps us convert these struct fields to proper naming conventions. Let’s see how we do that
package books
type Book struct {
ID int `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
Year int `json:"year"`
}
Now that our database is up and running let’s write database queries for Create, Read, Update, and Delete.
First of all, let’s import two required packages
package books
import (
"crud-api/internal/database"
"errors"
)
We are importing a database for accessing the DB object. Using this we can perform all the queries.
And errors we use for handling errors.
GetAllBooks Functionfunc GetAllBooks() ([]Book, error) {
rows, err := database.DB.Query("SELECT * FROM books")
if err != nil {
return nil, err
}
defer rows.Close()
...
}
Here we created a function called GetAllBooks which is responsible for getting all the books from the database.
It returns two values:
[ ]Book: A slice of struct Book.
error: Go’s error object.
Inside the function body, we are making a query to the SQL database using the DB variable’s Query function. It takes a string where we write a raw SQL query.
In this function, we need all the books with data. And to do that we wrote this query “SELECT * FROM books“. Which will get all the books from the database.
And then we checked if it threw any errors
After that, we defer call the rows.Close() to close it once this function is finished.
This rows object allows us to iterate over the results we get from the Query. And to iterate over the results we have rows.Next() function which goes over all the results.
We can take advantage of this function and create a for loop inside which we can fill all the data inside a slice of the book
func GetAllBooks() ([]Book, error) {
...
var books []Book
for rows.Next() {
var book Book
if err := rows.Scan(&book.ID, &book.Title, &book.Author, &book.Year); err != nil {
return nil, err
}
books = append(books, book)
}
return books, nil
}
Here we have created a temporary slice of the book variable. In this, we will fill in all the data.
Then we looped through rows.Next() which will go over all the rows and inside the for the body we are getting all mapping the data in a row to the Book struct using rows.Scan
And after that, we are appending that book to the books slice
And once every row is scanned we return it from the function.
NOTE: Make sure you have the book table in MySQL database: If you don’t have one then create it and then run the project, otherwise it will throw an error
You can run this query inside your MySQL Workbench
CREATE TABLE books ( id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(255) NOT NULL, author VARCHAR(255) NOT NULL, year INT NOT NULL );
GetAllBooks.// main.go
func main() {
config.LoadEnv()
database.Connect()
data, err := books.GetAllBooks()
if err != nil {
log.Fatal(err)
}
fmt.Println("Books:",data)
// TODO: Setup router
// TODO: Run server
}
Output:


This is great! Now let’s create a Handler function for this function. Because we need to send this data to the user too, right? Till now we just got the data from the database.
To do that head over to handler.go file
// handler.go
import (
"encoding/json"
"net/http"
)
Here encoding/json is used. This is used for converting JSON data into Go structures and Go data structures into JSON.
net/http is used for handling HTTP requests.
I’ll first show all the code to you and then we will understand what it does
package books
import (
"encoding/json"
"net/http"
)
func GetAllBooksHandler(w http.ResponseWriter, r *http.Request) {
books, err := GetAllBooks()
if err != nil {
http.Error(w, "Failed to fetch books", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(books)
}
func GetAllBooksHandler(w http.ResponseWriter, r *http.Request) {
Here we have two parameters:
w http.ResponseWriter: This is used for sending data back to the client.
r *http.Request: This is used for getting data from the client.
books, err := GetAllBooks()
GetAllBooks is called to retrieve a list of books. As we saw, that function will get the data from the database and return all the books with an err object.if err != nil {
http.Error(w, "Failed to fetch books", http.StatusInternalServerError)
return
}
If that function returns any error, we check it inside this if block and send back the HTTP error of InternalServerError.
And then exiting the function with return
Here, The http.Error function simplifies sending error responses. This has all the different types of errors you can throw according to your functionalities.
w.Header().Set("Content-Type", "application/json")
Content-Type header of the response to application/json. It informs the client that the response body contains JSON data.json.NewEncoder(w).Encode(books)
Now we need to encode the slice of a book in JSON format for the client. To do data we use json Encoder method.
This sent the data as the HTTP response. And it ensures compatibility with clients expecting data in JSON format.
Now, how these handlers are going to trigger? Of course when the user hits a certain URL, or to be specific an Endpoint from their end.
And so we want to return data for that particular endpoint/route right? For that, we need to create a route called /book.
And to create routes we are using the Chi package.
Go to routes.go and paste the below code
package routes
import (
"crud-api/internal/books"
"github.com/go-chi/chi/v5"
)
func SetupRouter() *chi.Mux {
r := chi.NewRouter()
r.Get("/books", books.GetAllBooksHandler)
return r
}
Here the SetupRouter function will configure all the routes.
First, we need to create an instance of chi router. This router will handle the incoming HTTP requests and route them to the appropriate handlers.
r := chi.NewRouter() - This line does that
And now using this variable r we can create a map of the routes to the handlers.
r.Get("/books", books.GetAllBooksHandler)
For different HTTP request, we have different HTTP Methods in r. POST, UPDATE, DELETE,etc
Here we need GET as we are getting the data from the database. Inside r.Get(), first, we need to give the route as /books. This means once this endpoint is hit by the client, the handler defined in the second parameter will be triggered.
And at the end, we are returning the pointer to the chi.Mux because we need it for the server in the main.go. You will see why below.
main.go filepackage main
import (
"log"
"net/http"
"github.com/red-star25/crud-api/config"
"github.com/red-star25/crud-api/database"
"github.com/red-star25/crud-api/internal/routes"
)
func main() {
config.LoadEnv()
database.Connect()
router := routes.SetupRouter()
port := config.GetEnvValue("PORT", "8080")
log.Printf("Server running on port %s", port)
log.Fatal(http.ListenAndServe(":"+port, router))
}
Inside the main function, we are loading the environment variables.
Then we connect to the database.
And then we call SetupRouter to configure all the routes.
To run the server we have ListenAndServe function inside http package. Inside we have to first pass the address and then the router that we set up. And we also have to check for any errors. So we are wrapping this function inside log.Fatal
Now let’s cross our fingers and check if everything is working. To check the functionality of our program. We are using the Postman application.
So open it up and run it


If you came this far to the end of this article, then congratulations for making it.
In the next article, we will be implementing the remaining CRUD operations which are Update, Delete, and Create. It will be fun because now we have the base setup for us, so now we just need to work on the functionalities.
Hope you learned something from this article. There are lots of blogs coming in the future with more amazing topics and projects and I am so excited to write about all of it.
See you in the next blog, until then….
