Go logo

GoLand

A cross-platform Go IDE with extended support for JavaScript, TypeScript, and databases

Tutorials

Create a Full-Stack App With Go and React

As a language that emerged over 14 years ago, Go has many use cases. From web development, APIs, and CLIs to Wasm, cloud technologies, and even AI-powered tools, its applications are broad. 

The same goes for demo projects, which have countless variations and purposes!

Now, wouldn’t it be great if there was a full-stack, API-oriented, and well-maintained demo project for Go? If you’ve ever experienced a demo project fail during a live presentation, then you’d certainly appreciate a more stable project! 

In this blog post, I’ll walk you through the process of creating such a project – a full-stack app using Go and React.

Check out the source code here to follow along.

Introducing Go Eats

Go Eats is an open-source project similar to the food delivery apps you’ve probably seen and used before. The project simulates the operations involved when placing an order in a real-world food delivery app.

To build Go Eats, I used Go as the programming language, Postgres as the database, React for the frontend, and NATS for messaging. While Go is a good choice for these complex full-stack applications, finding demos can be challenging.

I used GoLand for this project because it takes care of setting up the Go SDK, installing packages, and much more straight out of the box.

Let’s take a closer look at the entry point of the source code: main.go.

This code initializes several services and handlers, establishes a database connection, loads environment variables, and sets up middleware, NATS, and the WebSocket.

func main() {
	// load .env file
	err := godotenv.Load()
	if err != nil {
		log.Fatal("Error loading .env file")
	}

	env := os.Getenv("APP_ENV")
	db := database.New()
	// Create Tables
	if err := db.Migrate(); err != nil {
		log.Fatalf("Error migrating database: %s", err)
	}

	// Connect NATS
	natServer, err := nats.NewNATS("nats://127.0.0.1:4222")

	// WebSocket Clients
	wsClients := make(map[string]*websocket.Conn)

	s := handler.NewServer(db, true)

	// Initialize Validator
	validate := validator.New()

	// Middlewares List
	middlewares := []gin.HandlerFunc{middleware.AuthMiddleware()}

	// User
	userService := usr.NewUserService(db, env)
	user.NewUserHandler(s, "/user", userService, validate)

	// Restaurant
	restaurantService := restro.NewRestaurantService(db, env)
	restaurant.NewRestaurantHandler(s, "/restaurant", restaurantService)

	//.... For the sake of brevity, the full code snippet is omitted here.

	log.Fatal(s.Run())

}

The journey

I started by working on backend tasks, such as creating APIs, before moving to the frontend. I chose Gin as the framework to build these APIs.

While I could have gone with Gorilla, Echo, Fiber, or any other framework, I felt Gin would be a better choice for this project. It’s more mature and stable, it’s widely used, and it’s also somewhat of a personal preference. 

Before selecting any framework, you should carefully consider the problem you are trying to solve or the specific business goal you are trying to achieve. Then weigh up the potential solutions and opt for the best one for your specific use case.

The backend

While creating a demo app, it’s important to ensure that the application is flexible enough to accommodate future changes and refactoring.

For Go Eats, I implemented a service layer pattern, along with dependency injection. Additionally, I utilized handlers for request and response communication with the service layer, which manages business logic and interacts with the database.

	// User
	userService := usr.NewUserService(db, env)
	user.NewUserHandler(s, "/user", userService, validate)

	// Restaurant
	restaurantService := restro.NewRestaurantService(db, env)
	restaurant.NewRestaurantHandler(s, "/restaurant", restaurantService)

	// Reviews
	reviewService := review.NewReviewService(db, env)
	revw.NewReviewProtectedHandler(s, "/review", reviewService, middlewares, validate)

	// Cart
	cartService := cart_order.NewCartService(db, env, natServer)
	crt.NewCartHandler(s, "/cart", cartService, middlewares, validate)

The project structure shown above ensures that the project is modular and flexible. Remember that creating too many nested directories can add unnecessary complexity. Especially if you’re just starting your programming journey, it’s best to focus on simplicity rather than going through multiple layers. Keep in mind that your code will inevitably need to be refactored at some point.

The famous quote by Donald Knuth, “Premature optimization is the root of all evil,” feels particularly apt here.

I did something similar for the database. I used the lightweight Bun SQL client. 

However, I made sure that if I need to replace the ORM in the future, my code will be able to adapt to the new changes based on the interface I have defined below.

type Database interface {
	Insert(ctx context.Context, model any) (sql.Result, error)
	Delete(ctx context.Context, tableName string, filter Filter) (sql.Result, error)
	Select(ctx context.Context, model any, columnName string, parameter any) error
	SelectAll(ctx context.Context, tableName string, model any) error
	SelectWithRelation(ctx context.Context, model any, relations []string, Condition Filter) error
	SelectWithMultipleFilter(ctx context.Context, model any, Condition Filter) error
	Raw(ctx context.Context, model any, query string, args ...interface{}) error
	Update(ctx context.Context, tableName string, Set Filter, Condition Filter) (sql.Result, error)
	Count(ctx context.Context, tableName string, ColumnExpression string, columnName string, parameter any) (int64, error)
	Migrate() error
	HealthCheck() bool
	Close() error
}

This interface provides a blueprint for CRUD (create, read, update, and delete) operations and other common database interactions, ensuring that any struct implementing this interface can be used to perform these operations.

GoLand keeps track of class implementation, making navigation much smoother. 

To navigate to the implementation, press ⌘⌥B / Ctrl+Alt+B.

Most of the operations involve CRUD activities. I was also eager to experiment with server-sent events (SSE) and WebSockets, which I’ve not explored much in the past. This project was a good use case for these technologies.

Let’s begin with SSE, a technology that enables the server to push notifications, messages, and events to clients over an HTTP connection.

The code snippet below reads data from a JSON file and pushes the information so the client renders and displays the relevant data. 

func (s *AnnouncementHandler) flashNews(c *gin.Context) {
	_, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
	defer cancel()

	events, err := s.service.FlashEvents()
	if err != nil {
		c.AbortWithStatusJSON(500, gin.H{"error": err.Error()})
		return
	}

	// Set headers for SSE
	c.Header("Content-Type", "text/event-stream")
	c.Header("Cache-Control", "no-cache")
	c.Header("Connection", "keep-alive")

	ticker := time.NewTicker(6 * time.Second)
	defer ticker.Stop()

	eventIndex := 0

	for {
		select {
		case <-ticker.C:
			// Send the current event
			event := (*events)[eventIndex]
			c.SSEvent("message", event.Message)
			c.Writer.Flush()

			// Move to the next event
			eventIndex = (eventIndex + 1) % len(*events)
		case <-c.Request.Context().Done():
			ticker.Stop()
			return
		}
	}

}

WebSocket and NATS

Something else I’ve not worked with before but have always wanted to try is using WebSockets with the NATS messaging system. I evaluated these technologies as potential solutions for the project and ultimately decided to use them in the demo app.

Here’s how the messaging process in the Go Eats app functions: 

Whenever a new order is created, a message is published to NATS informing the subscriber about the newly placed order.

When the delivery person updates the order – whether it’s on the way, has failed, or has been delivered – the customer will receive this information in real time, a process that will be reflected in the UI.

The code below defines the two topics:

  • orders.new.*: Sends a notification to the customer when a new order is placed.
  • orders.status.*: Sends a notification to the customer when the delivery status is updated.

For each received message, the application logs the message, extracts the user ID and message data, and attempts to send the data to the corresponding WebSocket client. If sending fails, the app logs the error, closes the WebSocket connection, and removes the client from the active client’s map.

Note: Using map[string]*websocket.Conn to manage WebSocket connections with keys as client identifiers, such as userID, will not handle multiple connections from the same client, as the map keys must be unique. This is something I am planning to expand on in a future blog post. 

Why NATS over Kafka?

Kafka is capable of handling large amounts of data, offering database-level durability and being enterprise-ready. 

However, I have never used it before, and I needed something that would work well for my specific use case. In that context, I came across NATS, which is written in Go and is ideal for lightweight, low-latency messaging, particularly in microservices and cloud-native architectures. 

A key point I would like to highlight is its simplicity. NATS was easy to use even when just starting to work with it.

The code below initializes a NATS connection and defines methods to publish and subscribe to messages. It connects to a NATS server, publishes messages to a topic, and forwards received messages to WebSocket clients based on user IDs.

type NATS struct {
	Conn *nats.Conn
}

func NewNATS(url string) (*NATS, error) {
	nc, err := nats.Connect(url, nats.Name("food-delivery-nats"))
	if err != nil {
		log.Fatalf("Error connecting to NATS:: %s", err)
	}
	return &NATS{Conn: nc}, err
}

func (n *NATS) Pub(topic string, message []byte) error {
	err := n.Conn.Publish(topic, message)
	if err != nil {
		return err
	}
	return nil
}

func (n *NATS) Sub(topic string, clients map[string]*websocket.Conn) error {

	_, err := n.Conn.Subscribe(topic, func(msg *nats.Msg) {
		message := string(msg.Data)
		slog.Info("MESSAGE_REPLY_FROM_NATS", "RECEIVED_MESSAGE", message)
		userId, messageData := n.formatMessage(message)
		if conn, ok := clients[userId]; ok {
			err := conn.WriteMessage(websocket.TextMessage, []byte(messageData))
			if err != nil {
				log.Println("Error sending message to client:", err)
				conn.Close()
				delete(clients, userId)
			}
		}
	})
	if err != nil {
		return err
	}
	return nil
}

//.... For the sake of brevity, the full code snippet is omitted here.

The frontend

To build the Go Eats UI, I used React. I don’t come from a frontend background, and it was quite challenging to learn and apply unfamiliar concepts. Along the way, I took Stephen Grider’s Modern React with Redux course.

While I’m certainly no expert in React, the course helped a lot. That said, it was the use of GoLand, the JetBrains IDE for Go, which proved to be most valuable throughout this process.

GoLand has excellent support for full-stack development with less distraction and context switching. The IDE provides exceptional coding support for JavaScript, TypeScript, Dart, React, and many other languages.

Using GoLand, I can develop frontend and backend applications with the same IDE.

Finally, here is the final output of how the UI gets rendered through React. 

I haven’t gone into much detail about the process of developing the Go Eats frontend. However, if you’re interested, you can check out the source code here

Summary

As I reflect on my journey developing this demo app, I realize there is much to learn and many improvements that could be made.

Here are some key lessons from this experience:

  • Focus on functionality first!
    If you’re someone who is just starting to work with Go, don’t worry about making your app scalable or more modular by focusing on dependency injection right away. These concepts will eventually be required, and you will observe the patterns and restructure accordingly. Whatever you build, it won’t be perfect at first, and that’s okay! The journey is one of incremental and constant improvements. 
  • Prioritize testing!
    Don’t forget the importance of regular testing. Making sure you test your project frequently provides confidence that your features work as expected. While I have worked on tests for Go Eats, I still need to improve them further. Additionally, I have tried different testing libraries, such as Testcontainers, which help me test features in a real database instead of using mock ones.
  • Database design matters!
    Spending time on the database schema is extremely valuable. Additionally, there are database migrations, which I haven’t used, but they are quite important, especially when you’re looking for rollbacks, versioning changes, and ensuring that changes are applied in the correct order.

While I’m happy with what I’ve built so far, it’s just the beginning for Go Eats. Stay tuned for more updates soon! 

I’d also love to hear your feedback. If you’ve had similar experiences or if you have thoughts on what you liked or disliked, or if you have suggestions for improvement, please share them in the comments.

image description