Taxonomy of a Web App in Go

Have you ever struggled with naming variables, structs, packages when writing code? Of course, you have! It’s hard to name things in a way that is concise but still conveys the precise meaning of what it does. Other developers will have to read these names and understand the function or you may have to read it 6 months later and still grasp what your earlier self wrote.

I have been writing web servers in Go for a few years now and have developed a lexicon that works reasonably well across projects. I have borrowed generously from other languages but tried to keep the philosophy of Go intact.

Before we get started, it is important to note that you may not need to name things a certain way for small projects. But, as your project grows, it is nice to have consistency. It will make your code easier to reason about and keep it maintainable.

For this article, we will be focusing on web servers only. Let’s start at the bottom of the stack and build up.

Repository

For most web servers, you will need a storage layer. This layer is responsible for communicating with the database. It should not contain any business logic and stick to its responsibility for storing and retrieving records. Here’s what it may look like -

package users

func NewRepository(db *gorm.DB) *Repository {
	return &Repository{ ... }
}

type Repository struct {
	...
}

func (r *Repository) CreateUser(ctx context.context, user *User) error {
	...
}

func (r *Repository) GetUserByID(ctx context.context, id string) (*User, error) {
	...
}

func (r *Repository) NewUserCountInMonth(ctx context.Context, m time.Month, y int) (int, error) {
	...
}

In the example above, the Repository exists in the users package implying that the repository is responsible for objects related to a user. It can hold methods for basic CRUD operations and more complex queries like the NewUserCountInMonth that your database may support. The layer is not supposed to be “thin”, it can hold complex logic as long as it relates to querying your database.

It is important that this layer only talks to the database and relevant tables. There is little reason for it to import other repositories or anything else.

Service

This layer is where all of the complexities of your app can live. It holds all of the business logic related to its package. Naming its methods is very specific to your app but a good way to think about it is - what are the actions that a user is taking on your service? If a user is signing up, the method should be called SignupUser (as opposed to CreateUser in the repository layer).

package users

func NewSvc(...)  *Svc {
	return &Svc{ ... }
}

type Svc struct {
	... deps
	repo *Repository
}

func (s *Svc) SignupUser(ctx context.Context, p SignupUserParams) (*User, error) {
	...
}

The service layer is the most flexible of all layers. It can import the package repository, other services, clients, and more. It is important to keep the service layer focused on what actions a user takes to keep the naming easy to understand.

Your service layer must know nothing of your app exposing an HTTP server. This layer should contain pure business logic with no regard to handling HTTP requests/responses. That responsibility belongs in the router layer.

Router

A Router is responsible for handling HTTP requests. Here’s an example:

func NewRouter() *Router {
	return &Router{ ... }
}

type Router struct {
	...
	svc *Svc
}

func (ro *Router) HandleLogin(w http.ResponseWriter, r *http.Request) {
	// your code goes here..
}

func (ro *Router) HandleSignup(w http.ResponseWriter, r *http.Request) {
	// your code goes here..
}

The responsibilities of a router should be limited to read & validate the request body, call the service layer, and write a response. Try to keep the router free of any business logic. When in doubt of whether to put a piece of code in the router or the service layer, think of whether you would need to copy the code if you had to make a non-HTTP version of your app (ex. command-line version of your app). If the command-line version of your app would also need the logic, it probably belongs in the service layer.

Marshaler

If you are building a JSON API server and want to expose your models as JSON objects, start by annotating them with json tags. While this approach works, as your requirements on the frontend get more complex, you may want to add more fields. If these fields are simple i.e. they don’t require any database calls or any other service calls, you can implement the json.Marshaler interface on the model to accommodate these scenarios.

In some cases, you may want to execute some logic when transforming your model to a JSON object. For this, you can use a Marshaler

func NewMarshaler() *Marshaler {
	return &Marshaler{...}
}

type Marshaler struct {
	svc *Svc
	... other deps
}

func (m *Marshaler) MarshalUser(ctx context.Context, u *User) ([]byte, error) {
	// execute logic and return a marshaled payload
}


Finally, the marshaler can be used in the router layer to transform the response from the service layer into JSON that the client expects. While this gives you the most flexibility, it should be used sparingly.

Client

Clients are not specific to web apps but are quite useful. Let’s say you need to integrate Stripe in your server using their REST endpoints. The best way to structure this would be to create a package stripe, a struct in it called Client, and a method called NewClient() that can instantiate the client.

A “client” represents an object that is responsible for communicating with an external service.


I have used the naming strategy detailed in medium and large projects successfully. If you are working on a small project, the benefit of having consistent naming may not be obvious but as your project grows, consistent structure and naming can help keep your project maintainable for a long time.

Follow me on Twitter to get future post updates