Go + Graphql + Ent + Echo Boilerplate
It took me a while to figure out how I can use GraphQL with the ent ORM and
serve the GraphQL endpoint via the Echo framework.
I also wanted proper configuration management and a clean structure for my
application logic. It also should be easily testable, because I only write tests if it's convenient.
That's why I came up with Mega which could stand for something like
"Merge ent with GraphQL awesomeness" or "My earliest Go adventures".
What's inside the box
The challenge was to glue everything as modular as possible together to make replacing single components easy. I'm also not a fan of reflection, so the used libraries like ent, gqlgen, and Wire generate the necessary code instead. It adds little overhead, as you always have to run make ent|graphql|wire
after each modification. But it adds a lot of type safety and makes developing so much easier.
Give me some structure
One of the advantages of Go is its flexibility when it comes to structuring your application. It could also be challenging and requires continuous refactoring as your application grows.
I've worked on several web applications using Go in the past and tried a lot of different approaches. From throwing everything in one package (hey my PHP friends from the past) to making anything as modular as possible (waving over to the Java guys).
I've tried clean architecture and also domain-driven design. Both have pros and cons, but they felt too overkill for most of my Go projects. So I've ended up with the idea of encapsulating my application logic into small services that have a well-defined interface, are easily testable, and can be used with a variety of API frontends like REST, GraphQL, or gRPC.
Services to the rescue
All my application logic is contained in different services. Each service provides an interface to describe its API. Each service should but must not contain a test suite.
If you have a closer look at a service, you could see the following components:
type Service interface {
Get(ctx context.Context, id uuid.UUID) (*ent.User, error)
Create(ctx context.Context, user model.AddUserInput) (*ent.User, error)
}
The Service
interface defines all the offered methods. It also helps to decouple your code if you reference via interfaces instead of specific types.
type User struct {
client *db.Client
}
The User
struct bundles all service methods together and contains the needed dependencies for the service. If you need to send emails, you could, for example, inject a mailer dependency that provides functionality to send mails.
func New(client *db.Client) Service {
return &User{client: client}
}
The New
method simply acts as an initializer.
func (s *User) Get(ctx context.Context, id uuid.UUID) (*ent.User, error) {
return s.client.User.Get(ctx, id)
}
And last but not least, the Get
method on the User
struct is the actual application logic.
Getting started
I've decided to use make
for automating all the tasks. That's why all commands can be run via make
.
make ent
- Generates the required code from your defined models.
make graphql
- Generates the GraphQL resolvers and models from the schema.
make wire
- Generates the dependency injection code.
make all
- Executes the ent
, graphql
, and wire
task all together.
make run
- Starts the web server.
make test
- Executes all tests.
After each modification of the GraphQL schema, the models or the dependency graph you need to run either make ent|graphql|wire
or make all
.
Defining your GraphQl schema
The schema is located under the /graph/schema
directory. It contains two different types of files. The "main" schema in schema.graphqls
contains common types like a Timestamp
.
And the service-specific schemas like user.graphqls
contain the query and mutation definitions for the respective service.
Splitting your schema into multiple files also makes it much easier to implement the resolvers later, as they only contain your service-specific queries and mutations and not those of the whole application.
Configuration
As I'm using Viper as configuration management, it is easy to have specific configurations for different environments. Just copy the config.yaml
in the repository's root and adapt the setting for a different environment.
The default make run
command takes the default config file named config.yml
in the repo's root.
To use your own config file, simply supply the -config mynewconfig.yml
argument to the go run
command.
go run cmd/main.go -config dev.yml
First steps
If you execute make run
, it will start the server on http://127.0.0.1:8080. The default GraphQL query endpoint is under 127.0.0.1:8080/query. There is also a GraphQL playground which is available via 127.0.0.1:8080/playground.
To insert your first user, simply run the following mutation:
mutation {
createUser(user: {name: "Foo", age: 10}) {
id,
createdAt
}
}
It will return the following response:
{
"data": {
"createUser": {
"id": "da612c7d-494c-4164-9afc-d353c3e923cf",
"createdAt": "2022-01-24T00:09:11+01:00"
}
}
}
You can see a new user with the ID da612c7d-494c-4164-9afc-d353c3e923cf
is created.
To query for that user, you can use the following query:
query {
user(id: "39b066c7-4b34-4a41-9d21-0b55b16ff0eb") {
id
createdAt
}
}
And it returns the same response as the mutation above:
{
"data": {
"user": {
"id": "39b066c7-4b34-4a41-9d21-0b55b16ff0eb",
"createdAt": "2022-01-24T00:17:02+01:00"
}
}
}