Skip to main content

A beginner's guide to creating a web-app in Go using Ent

· 19 min read

Ent is an open-source entity framework for Go. It is similar to more traditional ORMs, but has a few distinct features that have made it very popular in the Go community. Ent was first open-sourced by Ariel in 2019, when he was working at Facebook. Ent grew from the pains of managing the development of applications with very large and complex data models and ran successfully inside Facebook for a year before open-sourcing it. After graduating from Facebook Open Source, Ent joined the Linux Foundation in September 2021.

This tutorial is intended for Ent and Go novices who want to start by building a simple project: a very minimal content management system.

Over the last few years, Ent has become one of the fastest growing ORMs in Go:

Source: @ossinsight_bot on Twitter, November 2022

Some of Ent's most cited features are:

  • A type-safe Go API for working with your database. Forget about using interface{} or reflection to work with your database. Use pure Go that your editor understands and your compiler enforces.

  • Model your data in graph semantics - Ent uses graph semantics to model your application's data. This makes it very easy to traverse complex datasets in a simple API.

    Let’s say we want to get all users that are in groups that are about dogs. Here are two ways to write something like this with Ent:

    // Start traversing from the topic.
    client.Topic.Query().
    Where(topic.Name("dogs")).
    QueryGroups().
    QueryUsers().
    All(ctx)

    // OR: Start traversing from the users and filter.
    client.User.Query().
    Where(
    user.HasGroupsWith(
    group.HasTopicsWith(
    topic.Name("dogs"),
    ),
    ),
    ).
    All(ctx)
  • Automatically generate servers - whether you need GraphQL, gRPC or an OpenAPI compliant API layer, Ent can generate the necessary code you need to create a performant server on top of your database. Ent will generate both the third-party schemas (GraphQL types, Protobuf messages, etc.) and optimized code for the repetitive tasks for reading and writing from the database.
  • Bundled with Atlas - Ent is built with a rich integration with Atlas, a robust schema management tool with many advanced capabilities. Atlas can automatically plan schema migrations for you as well as verify them in CI or deploy them to production for you. (Full disclosure: Ariel and I are the creators and maintainers)

Prerequisites​

Supporting repo

You can find of the code shown in this tutorial in this repo.

Step 1: Setting up the database schema​

You can find the code described in this step in this commit.

Let's start by initializing our project using go mod init:

go mod init github.com/rotemtam/ent-blog-example

Go confirms our new module was created:

go: creating new go.mod: module github.com/rotemtam/ent-blog-example

The first thing we will handle in our demo project will be to setup our database. We create our application data model using Ent. Let's fetch it using go get:

go get -u entgo.io/ent@master

Once installed, we can use the Ent CLI to initialize the models for the two types of entities we will be dealing with in this tutorial: the User and the Post.

go run -mod=mod entgo.io/ent/cmd/ent new User Post

Notice that a few files are created:

.
`-- ent
|-- generate.go
`-- schema
|-- post.go
`-- user.go

2 directories, 3 files

Ent created the basic structure for our project:

  • generate.go - we will see in a bit how this file is used to invoke Ent's code-generation engine.
  • The schema directory, with a bare ent.Schema for each of the entities we requested.

Let's continue by defining the schema for our entities. This is the schema definition for User:

// Fields of the User.  
func (User) Fields() []ent.Field {
return []ent.Field{
field.String("name"),
field.String("email").
Unique(),
field.Time("created_at").
Default(time.Now),
}
}

// Edges of the User.
func (User) Edges() []ent.Edge {
return []ent.Edge{
edge.To("posts", Post.Type),
}
}

Observe that we defined three fields, name, email and created_at (which takes the default value of time.Now()). Since we expect emails to be unique in our system we added that constraint on the email field. In addition, we defined an edge named posts to the Post type. Edges are used in Ent to define relationships between entities. When working with a relational database, edges are translated into foreign keys and association tables.

// Post holds the schema definition for the Post entity.
type Post struct {
ent.Schema
}

// Fields of the Post.
func (Post) Fields() []ent.Field {
return []ent.Field{
field.String("title"),
field.Text("body"),
field.Time("created_at").
Default(time.Now),
}
}

// Edges of the Post.
func (Post) Edges() []ent.Edge {
return []ent.Edge{
edge.From("author", User.Type).
Unique().
Ref("posts"),
}
}

On the Post schema, we defined three fields as well: title, body and created_at. In addition, we defined an edge named author from Post to the User entity. We marked this edge as Unique because in our budding system, each post can have only one author. We used Ref to tell Ent that this edge's back reference is the posts edge on the User.

Ent's power stems from it's code-generation engine. When developing with Ent, whenever we make any change to our application schema, we must invoke Ent's code-gen engine to regenerate our database access code. This is what allows Ent to maintain a type-safe and efficient Go API for us.

Let's see this in action, run:

go generate ./...

Observe that a whole lot of new Go files were created for us:

.
`-- ent
|-- client.go
|-- context.go
|-- ent.go
|-- enttest
| `-- enttest.go
/// .. Truncated for brevity
|-- user_query.go
`-- user_update.go

9 directories, 29 files
info

If you're interested to see what the actual database schema for our application looks like, you can use a useful tool called entviz:

go run -mod=mod ariga.io/entviz ./ent/schema

To view the result, click here.

Once we have our data model defined, let's create the database schema for it.

To install the latest release of Atlas, simply run one of the following commands in your terminal, or check out the Atlas website:

curl -sSf https://atlasgo.sh | sh

With Atlas installed, we can create the initial migration script:

atlas migrate diff add_users_posts \
--dir "file://ent/migrate/migrations" \
--to "ent://ent/schema" \
--dev-url "docker://mysql/8/ent"

Observe that two new files were created:

ent/migrate/migrations
|-- 20230226150934_add_users_posts.sql
`-- atlas.sum

The SQL file (the actual file name will vary on your machine depending on the timestamp in which you run atlas migrate diff) contains the SQL DDL statements required to set up the database schema on an empty MySQL database:

-- create "users" table  
CREATE TABLE `users` (`id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(255) NOT NULL, `email` varchar(255) NOT NULL, `created_at` timestamp NOT NULL, PRIMARY KEY (`id`), UNIQUE INDEX `email` (`email`)) CHARSET utf8mb4 COLLATE utf8mb4_bin;
-- create "posts" table
CREATE TABLE `posts` (`id` bigint NOT NULL AUTO_INCREMENT, `title` varchar(255) NOT NULL, `body` longtext NOT NULL, `created_at` timestamp NOT NULL, `user_posts` bigint NULL, PRIMARY KEY (`id`), INDEX `posts_users_posts` (`user_posts`), CONSTRAINT `posts_users_posts` FOREIGN KEY (`user_posts`) REFERENCES `users` (`id`) ON UPDATE NO ACTION ON DELETE SET NULL) CHARSET utf8mb4 COLLATE utf8mb4_bin;

To setup our development environment, let's use Docker to run a local mysql container:

docker run --rm --name entdb -d -p 3306:3306 -e MYSQL_DATABASE=ent -e MYSQL_ROOT_PASSWORD=pass mysql:8

Finally, let's run the migration script on our local database:

atlas migrate apply --dir file://ent/migrate/migrations \
--url mysql://root:pass@localhost:3306/ent

Atlas reports that it successfully created the tables:

Migrating to version 20230220115943 (1 migrations in total):

-- migrating version 20230220115943
-> CREATE TABLE `users` (`id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(255) NOT NULL, `email` varchar(255) NOT NULL, `created_at` timestamp NOT NULL, PRIMARY KEY (`id`), UNIQUE INDEX `email` (`email`)) CHARSET utf8mb4 COLLATE utf8mb4_bin;
-> CREATE TABLE `posts` (`id` bigint NOT NULL AUTO_INCREMENT, `title` varchar(255) NOT NULL, `body` longtext NOT NULL, `created_at` timestamp NOT NULL, `post_author` bigint NULL, PRIMARY KEY (`id`), INDEX `posts_users_author` (`post_author`), CONSTRAINT `posts_users_author` FOREIGN KEY (`post_author`) REFERENCES `users` (`id`) ON UPDATE NO ACTION ON DELETE SET NULL) CHARSET utf8mb4 COLLATE utf8mb4_bin;
-- ok (55.972329ms)

-------------------------
-- 67.18167ms
-- 1 migrations
-- 2 sql statements

Step 2: Seeding our database​

info

The code for this step can be found in this commit.

While we are developing our content management system, it would be sad to load a web page for our system and not see content for it. Let's start by seeding data into our database and learn some Ent concepts.

To access our local MySQL database, we need a driver for it, use go get to fetch it:

go get -u github.com/go-sql-driver/mysql

Create a file named main.go and add this basic seeding script.

package main

import (
"context"
"flag"
"fmt"
"log"

"github.com/rotemtam/ent-blog-example/ent"

_ "github.com/go-sql-driver/mysql"
"github.com/rotemtam/ent-blog-example/ent/user"
)

func main() {
// Read the connection string to the database from a CLI flag.
var dsn string
flag.StringVar(&dsn, "dsn", "", "database DSN")
flag.Parse()

// Instantiate the Ent client.
client, err := ent.Open("mysql", dsn)
if err != nil {
log.Fatalf("failed connecting to mysql: %v", err)
}
defer client.Close()

ctx := context.Background()
// If we don't have any posts yet, seed the database.
if !client.Post.Query().ExistX(ctx) {
if err := seed(ctx, client); err != nil {
log.Fatalf("failed seeding the database: %v", err)
}
}
// ... Continue with server start.
}

func seed(ctx context.Context, client *ent.Client) error {
// Check if the user "rotemtam" already exists.
r, err := client.User.Query().
Where(
user.Name("rotemtam"),
).
Only(ctx)
switch {
// If not, create the user.
case ent.IsNotFound(err):
r, err = client.User.Create().
SetName("rotemtam").
SetEmail("r@hello.world").
Save(ctx)
if err != nil {
return fmt.Errorf("failed creating user: %v", err)
}
case err != nil:
return fmt.Errorf("failed querying user: %v", err)
}
// Finally, create a "Hello, world" blogpost.
return client.Post.Create().
SetTitle("Hello, World!").
SetBody("This is my first post").
SetAuthor(r).
Exec(ctx)
}

As you can see, this program first checks if any Post entity exists in the database, if it does not it invokes the seed function. This function uses Ent to retrieve the user named rotemtam from the database and in case it does not exist, tries to create it. Finally, the function creates a blog post with this user as its author.

Run it:

 go run main.go -dsn "root:pass@tcp(localhost:3306)/ent?parseTime=true"

Step 3: Creating the home page​

info

The code described in this step can be found in this commit

Let's now create the home page of the blog. This will consist of a few parts:

  1. The view - this is a Go html/template that renders the actual HTML the user will see.
  2. The server code - this contains the HTTP request handlers that our users' browsers will communicate with and will render our templates with data they retrieve from the database.
  3. The router - registers different paths to handlers.
  4. A unit test - to verify our server behaves correctly.

The view​

Go has an excellent templating engine that comes in two flavors: text/template for rendering general purpose text and html/template which had some extra security features to prevent code injection when working with HTML documents. Read more about it here .

Let's create our first template that will be used to display a list of blog posts. Create a new file named templates/list.tmpl:

<html>
<head>
<title>My Blog</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-GLhlTQ8iRABdZLl6O3oVMWSktQOp6b7In1Zl3/Jr59b6EGGoI1aFkw7cmDA6j6gD" crossorigin="anonymous">

</head>
<body>
<div class="col-lg-8 mx-auto p-4 py-md-5">
<header class="d-flex align-items-center pb-3 mb-5 border-bottom">
<a href="/" class="d-flex align-items-center text-dark text-decoration-none">
<span class="fs-4">Ent Blog Demo</span>
</a>
</header>

<main>
<div class="row g-5">
<div class="col-md-12">
{{- range . }}
<h2>{{ .Title }}</h2>
<p>
{{ .CreatedAt.Format "2006-01-02" }} by {{ .Edges.Author.Name }}
</p>
<p>
{{ .Body }}
</p>
{{- end }}
</div>

</div>
</main>
<footer class="pt-5 my-5 text-muted border-top">
<p>
This is the Ent Blog Demo. It is a simple blog application built with Ent and Go. Get started:
</p>
<pre>go get entgo.io/ent</pre>
</footer>
</div>

<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"
integrity="sha384-w76AqPfDkMBDXo30jS1Sgez6pr3x5MlQ1ZAGC+nuZB+EYdgRZgiwxhTBTkF7CXvN"
crossorigin="anonymous"></script>
</body>
</html>

Here we are using a modified version of the Bootstrap Starter Template as the basis of our UI. Let's highlight the important parts. As you will see below, in our index handler, we will pass this template a slice of Post objects.

Inside the Go-template, whatever we pass to it as data is available as ".", this explains this line, where we use range to iterate over each post:

{{- range . }}

Next, we print the title, creation time and the author name, via the Author edge:

<h2>{{ .Title }}</h2>
<p>
{{ .CreatedAt.Format "2006-01-02" }} by {{ .Edges.Author.Name }}
</p>

Finally, we print the post body and close the loop.

    <p>
{{ .Body }}
</p>
{{- end }}

After defining the template, we need to make it available to our program. We embed this template into our binary using the embed package (docs):

var (  
//go:embed templates/*
resources embed.FS
tmpl = template.Must(template.ParseFS(resources, "templates/*"))
)

Server code​

We continue by defining a type named server and a constructor for it, newServer. This struct will have receiver methods for each HTTP handler we create and binds the Ent client we created at init to the server code.

type server struct {
client *ent.Client
}

func newServer(client *ent.Client) *server {
return &server{client: client}
}

Next, let's define the handler for our blog home page. This page should contain a list of all available blog posts:

// index serves the blog home page
func (s *server) index(w http.ResponseWriter, r *http.Request) {
posts, err := s.client.Post.
Query().
WithAuthor().
All(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := tmpl.Execute(w, posts); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}

Let's zoom in on the Ent code here that is used to retrieve the posts from the database:

// s.client.Post contains methods for interacting with Post entities
s.client.Post.
// Begin a query.
Query().
// Retrieve the entities using the `Author` edge. (a `User` instance)
WithAuthor().
// Run the query against the database using the request context.
All(r.Context())

The router​

To manage the routes for our application, let's use go-chi, a popular routing library for Go.

go get -u github.com/go-chi/chi/v5

We define the newRouter function that sets up our router:

// newRouter creates a new router with the blog handlers mounted.
func newRouter(srv *server) chi.Router {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Get("/", srv.index)
return r
}

In this function, we first instantiate a new chi.Router, then register two middlewares:

  • middleware.Logger is a basic access logger that prints out some information on every request our server handles.
  • middleware.Recoverer recovers from when our handlers panic, preventing a case where our entire server will crash because of an application error.

Finally, we register the index function of the server struct to handle GET requests to the / path of our server.

A unit test​

Before wiring everything together, let's write a simple unit test to check that our code works as expected.

To simplify our tests we will install the SQLite driver for Go which allows us to use an in-memory database:

go get -u github.com/mattn/go-sqlite3

Next, we install testify, a utility library that is commonly used for writing assertions in tests.

go get github.com/stretchr/testify 

With these dependencies installed, create a new file named main_test.go:

package main

import (
"context"
"io"
"net/http"
"net/http/httptest"
"testing"

_ "github.com/mattn/go-sqlite3"
"github.com/rotemtam/ent-blog-example/ent/enttest"
"github.com/stretchr/testify/require"
)

func TestIndex(t *testing.T) {
// Initialize an Ent client that uses an in memory SQLite db.
client := enttest.Open(t, "sqlite3", "file:ent?mode=memory&cache=shared&_fk=1")
defer client.Close()

// seed the database with our "Hello, world" post and user.
err := seed(context.Background(), client)
require.NoError(t, err)

// Initialize a server and router.
srv := newServer(client)
r := newRouter(srv)

// Create a test server using the `httptest` package.
ts := httptest.NewServer(r)
defer ts.Close()

// Make a GET request to the server root path.
resp, err := ts.Client().Get(ts.URL)

// Assert we get a 200 OK status code.
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)

// Read the response body and assert it contains "Hello, world!"
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Contains(t, string(body), "Hello, World!")
}

Run the test to verify our server works correctly:

go test ./...

Observe our test passes:

ok      github.com/rotemtam/ent-blog-example    0.719s
? github.com/rotemtam/ent-blog-example/ent [no test files]
? github.com/rotemtam/ent-blog-example/ent/enttest [no test files]
? github.com/rotemtam/ent-blog-example/ent/hook [no test files]
? github.com/rotemtam/ent-blog-example/ent/migrate [no test files]
? github.com/rotemtam/ent-blog-example/ent/post [no test files]
? github.com/rotemtam/ent-blog-example/ent/predicate [no test files]
? github.com/rotemtam/ent-blog-example/ent/runtime [no test files]
? github.com/rotemtam/ent-blog-example/ent/schema [no test files]
? github.com/rotemtam/ent-blog-example/ent/user [no test files]

Putting everything together​

Finally, let's update our main function to put everything together:

func main() {  
// Read the connection string to the database from a CLI flag.
var dsn string
flag.StringVar(&dsn, "dsn", "", "database DSN")
flag.Parse()

// Instantiate the Ent client.
client, err := ent.Open("mysql", dsn)
if err != nil {
log.Fatalf("failed connecting to mysql: %v", err)
}
defer client.Close()

ctx := context.Background()
// If we don't have any posts yet, seed the database.
if !client.Post.Query().ExistX(ctx) {
if err := seed(ctx, client); err != nil {
log.Fatalf("failed seeding the database: %v", err)
}
}
srv := newServer(client)
r := newRouter(srv)
log.Fatal(http.ListenAndServe(":8080", r))
}

We can now run our application and stand amazed at our achievement: a working blog front page!

 go run main.go -dsn "root:pass@tcp(localhost:3306)/test?parseTime=true"

Step 4: Adding content​

info

You can follow the changes in this step in this commit.

No content management system would be complete without the ability, well, to manage content. Let's demonstrate how we can add support for publishing new posts on our blog.

Let's start by creating the backend handler:

// add creates a new blog post.
func (s *server) add(w http.ResponseWriter, r *http.Request) {
author, err := s.client.User.Query().Only(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := s.client.Post.Create().
SetTitle(r.FormValue("title")).
SetBody(r.FormValue("body")).
SetAuthor(author).
Exec(r.Context()); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
http.Redirect(w, r, "/", http.StatusFound)
}

As you can see, the handler currently loads the only user from the users table (since we have yet to create a user management system or login capabilities). Only will fail unless exactly one result is retrieved from the database.

Next, our handler creates a new post, by setting the title and body fields to values retrieved from r.FormValue. This is where Go stores all of the form input passed to an HTTP request.

After creating the handler, we should wire it to our router:

// newRouter creates a new router with the blog handlers mounted.
func newRouter(srv *server) chi.Router {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Get("/", srv.index)
r.Post("/add", srv.add)
return r
}

Next, we can add an HTML <form> component that will be used by our user to write their content:

<div class="col-md-12">
<hr/>
<h2>Create a new post</h2>
<form action="/add" method="post">
<div class="mb-3">
<label for="title" class="form-label">Title</label>
<input name="title" type="text" class="form-control" id="title" placeholder="Once upon a time..">
</div>
<div class="mb-3">
<label for="body" class="form-label">Body</label>
<textarea name="body" class="form-control" id="body" rows="8"></textarea>
</div>
<div class="mb-3">
<button type="submit" class="btn btn-primary mb-3">Post</button>
</div>
</form>
</div>

Also, let's add a nice touch, where we display the blog posts from newest to oldest. To do this, modify the index handler to order the posts in a descending order using the created_at column:

posts, err := s.client.Post.
Query().
WithAuthor().
Order(ent.Desc(post.FieldCreatedAt)).
All(ctx)

Finally, let's add another unit test that verifies the add post flow works as expected:

func TestAdd(t *testing.T) {
client := enttest.Open(t, "sqlite3", "file:ent?mode=memory&cache=shared&_fk=1")
defer client.Close()
err := seed(context.Background(), client)
require.NoError(t, err)

srv := newServer(client)
r := newRouter(srv)

ts := httptest.NewServer(r)
defer ts.Close()

// Post the form.
resp, err := ts.Client().PostForm(ts.URL+"/add", map[string][]string{
"title": {"Testing, one, two."},
"body": {"This is a test"},
})
require.NoError(t, err)
// We should be redirected to the index page and receive 200 OK.
require.Equal(t, http.StatusOK, resp.StatusCode)

body, err := io.ReadAll(resp.Body)
require.NoError(t, err)

// The home page should contain our new post.
require.Contains(t, string(body), "This is a test")
}

Let's run the test:

go test ./...

And everything works!

ok      github.com/rotemtam/ent-blog-example    0.493s
? github.com/rotemtam/ent-blog-example/ent [no test files]
? github.com/rotemtam/ent-blog-example/ent/enttest [no test files]
? github.com/rotemtam/ent-blog-example/ent/hook [no test files]
? github.com/rotemtam/ent-blog-example/ent/migrate [no test files]
? github.com/rotemtam/ent-blog-example/ent/post [no test files]
? github.com/rotemtam/ent-blog-example/ent/predicate [no test files]
? github.com/rotemtam/ent-blog-example/ent/runtime [no test files]
? github.com/rotemtam/ent-blog-example/ent/schema [no test files]
? github.com/rotemtam/ent-blog-example/ent/user [no test files]

A passing unit test is great, but let's verify our changes visually:

Our form appears - great! After submitting it:

Our new post is displayed. Well done!

Wrapping up​

In this post we demonstrated how to build a simple web application with Ent and Go. Our app is definitely bare but it deals with many of the bases that you will need to cover when building an application: defining your data model, managing your database schema, writing server code, defining routes and building a UI.

As things go with introductory content, we only touched the tip of the iceberg of what you can do with Ent, but I hope you got a taste for some of its core features.

For more Ent news and updates: