-
Notifications
You must be signed in to change notification settings - Fork 0
Folder Structure
I have been building graphql APIs in Golang for a while. Based on what I have learned so far, I have created this framework, focussing on the following principles:
- Single Responsibility
- Configurability
- Testability
- Dependency Isolation
- Environment Parity
One question I get asked frequently: Why did I build a custom framework when there are so many good ones available in the market? The answer is pretty straightforward, I wanted a super-fast framework with best practices in place and without any bloat. So I started by connecting a few pieces together & slowly we reached a place where I was satisfied with the speed & developer productivity that I was on the lookout for. I have nothing against other open source frameworks out there. At the time when I started writing my service with this framework, none of the frameworks supported all the 5 points mentioned above & the only choice I had was to wire different libraries together & build our own.
The general idea of the framework is to divide each responsibility into go packages and abstract each part of the stack as much as possible. The code structure has evolved over time and will keep evolving as we find more use cases & edge conditions throughout the lifecycle of building out products that users ♥.
To give you a general idea of the framework, the following technologies are used in the stack:
- Golang (1.17 and above)
- Beego ORM (Just ORM from the beego framework)
- Gin (For handling REST requests, if any)
- PostgreSQL for storing relational data
- GraphQL for API abstractions to facilitate inter-service & client-server communication
The most important idea behind the framework is the concept of dependency injection. If you don’t know about dependency injection, it is a pretty simple concept that says:
ℹ️ Decouple your dependenciesIn simple terms, do not import your dependencies directly into the target file. Instead, inject them through an initialization method. To support this idea, (almost) each file is defined in the form of a public interface with a private struct & an initialization method that takes a dependency as the input.
For example, let's assume the following scenario:
- You have a type called
Tyre
in theparts
package - You are creating a
Car
object which takes theTyre
as a dependency
This is how your code will look in car.go
package vehicle
import "service/parts"
type Car interface {
Drive() error
}
type car struct {
tyrePart parts.Tyre
}
func (c *car) Drive() error {
// drive the car
err := c.tyrePart.Move(ctx)
return err
}
func NewCar(tyrePart parts.Tyre) Car {
return &car{
tyrePart: tyrePart,
}
}
What we did here was:
- Create a
Car
interface that defines what functions a car can have - Define a
car
struct that has tyre as one of the parts as a property - Create an initialization method called
NewCar
which is nothing but a constructor for the car object and takes an instance of the tyre as a dependency & returns an object of theCar
type.
When you want to use drive a car, this is what your code will look like:
package main
import (
"fmt"
"service/parts"
"service/vehicle"
)
func main() {
// intialize the tyre object
t := parts.NewTyre()
// initialize the car
c := vehicle.NewCar(t)
// drive
_ = c.Drive()
}
The biggest advantage of using this method is that we can mock all of our dependencies when we write unit tests. It also decouples one piece of code from another which makes debugging easier.
Now that you understand the idea behind the framework, let's jump into the folder structure in the following sections. We will dive deep into each package in further documents.
This as you might already know is the starting point of any golang app. The same is the case here. We try to keep main.go
as small as possible & use it only to bootstrap dependencies and start the service components.
The config
package as the name says handles the configuration of the app.
The constants
package holds app-wide constants in the package. By default it has 2 files:
-
app.go
orcommon.go
: This file is for common constants used in the app all across. If you don’t know where a constant should go, this is probably the best place for it. -
errors.go
: This file is for storing error types sent in the API response. To define an error constant, 2 things are needed:- Add an error constant in the
const
block with a variable name that correctly describes the error message - Add an error string for that variable in the
ErrorString
map. This error string is what will be returned as the error message in the API response
- Add an error constant in the
The logger
package is a straightforward package that is a wrapper over the https://github.com/sirupsen/logrus package. Nothing fancy here, you can use logger.Log.Info
, logger.Log.Error
, logger.Log.Warn
etc just like you would use any logging library.
The instance package is the singleton package & struct for handling use cases where a single instance should be created, for example, database connections, HTTP clients, queue connections etc. The Init
method in the instance.go
initializes all the singleton connections using [once.Do](https://golang.org/pkg/sync/#Once.Do).
So, let's say if you want to add support for another datastore in the service, you will need to initialize the connection in the Init
method & expose the connection like the DB
method present in the file.
One important method in the file is Destroy
, which acts like a destructor for the instance. This will be called automatically when the app crashes or shuts down and should be used to close the connections.
The utils
package is the generic utility package that you might have seen in other codebases. Yes, we know that golang [doesn’t recommend](https://dave.cheney.net/2019/01/08/avoid-package-names-like-base-util-or-common) using a package called utils
and we agree with that but there is just no better way we could think of which can have small utility methods.
The db
package handles all db related declarations. The declaration is an important keyword here. We only declare database models & migrations in this package without defining their behavior (read queries). We do that in the repository
package, described below.
The db package has 2 subfolders:
- models: This package contains all the database models in the form of structs. Each model has a separate file with ORM declarations & Serializers/Deserializers
- migrations: This folder contains all the database migrations for the Postgres database.
More information about this package & usage is here: https://creatorstack.atlassian.net/wiki/spaces/TD/pages/521568021
The repository
package handles the how of the models defined in the db package. Each model has a different repository file that defines the functionality of the model.
More information about this package & usage is here: https://creatorstack.atlassian.net/wiki/spaces/TD/pages/521568021
The graph
package holds the graphql schema & resolver declarations for the graphql API
More information about this package & usage is here: https://creatorstack.atlassian.net/wiki/spaces/TD/pages/521568036
The runner
package is a pretty important package in the framework. It starts all the processes that required to run a service. In 90% of the cases, you won’t have to deal with this package unless you are adding a new type of process, for ex, a new HTTP server, queueing system, etc.
The api
package contains all the API services & business logic which is used to serve responses to the GraphQL/REST APIs.
More information about this package & usage is here: https://creatorstack.atlassian.net/wiki/spaces/TD/pages/521568036
The queue
package contains all the queue subscription & publishing business logic which is used for background processing
More information about this package & usage is here: https://creatorstack.atlassian.net/wiki/spaces/TD/pages/521568043
The docker
folder contains the docker configuration for running the service code.
More information about this folder & usage is here: https://creatorstack.atlassian.net/wiki/spaces/TD/pages/521502519
The vscode
folder is a special folder that contains vscode launch configurations for running & debugging the application code.