Skip to content
gohacks.dev
Databases

Ent gRPC and OpenAPI Extensions for Generated Services

Generate a .proto service and an OpenAPI 3 spec from an Ent schema with entproto and entoas, validated by protoc, and see which operations they expose

12 min read
Summarize in AI
Flat illustration of a gateway connected to a ring of services, with the OpenAPI Initiative logo.

A schema in Ent already knows your entities, their fields, their types and the relations between them. That is nearly everything a CRUD API contract says, so the Ent project ships extensions that write the contract for you: entproto produces protobuf messages and a gRPC service, and entoas produces an OpenAPI 3 document. A third, entgql, produces a GraphQL schema (covered separately). This article runs the first two on one schema, prints what they generate and checks the protobuf with protoc, then explains the decisions that make a generated API safe to publish. It does not claim more than it ran: I generated the contracts and validated the proto, and did not build a gRPC server or an ogent REST server from them.

Tested with Go 1.25.6, Ent v0.14.5, entgo.io/contrib (entproto, entoas), ogen, protoc from Homebrew, macOS on Apple Silicon (arm64). The generated Go gRPC server and ogent server were not built.

It builds on the Ent tutorial and the codegen workflow. For handwritten services, see gRPC in Go and the REST API with chi and Ent.

The idea, and the risk

The appeal is obvious: describe a User once and get the table, the Go client, the protobuf message, the RPC service and the REST documentation. Change a field and every contract moves with it. The risk is just as obvious. A generated API exposes your database shape. Every field you add to the schema can appear in a public contract, and every client that depends on it is a client you cannot break. The extensions give you controls (field numbers, operation selection, policies) and the discipline is in using them.

Use generated APIs for internal services, admin tools and back office APIs, where the entity model is the interface. For a public API, treat the output as a starting point and shape the contract by hand.

The schema

The User entity carries the annotations for both extensions. entproto.Message() marks it as a protobuf message, entproto.Service(...) adds an RPC service with the listed methods, and the three entoas annotations name the REST operations:

ent/schema/user.goGo
package schema

import (
	"entgo.io/contrib/entoas"
	"entgo.io/contrib/entproto"
	"entgo.io/ent"
	"entgo.io/ent/schema"
	"entgo.io/ent/schema/edge"
	"entgo.io/ent/schema/field"
)

type User struct{ ent.Schema }

func (User) Annotations() []schema.Annotation {
	return []schema.Annotation{
		entproto.Message(),
		entproto.Service(entproto.Methods(entproto.MethodCreate | entproto.MethodGet | entproto.MethodList)),
		entoas.CreateOperation(),
		entoas.ReadOperation(),
		entoas.ListOperation(),
	}
}

func (User) Fields() []ent.Field {
	return []ent.Field{
		field.String("name").Annotations(entproto.Field(2)),
		field.String("email").Unique().Annotations(entproto.Field(3)),
	}
}

func (User) Edges() []ent.Edge {
	return []ent.Edge{edge.To("posts", Post.Type).Annotations(entproto.Field(4))}
}

Post is a message only:

ent/schema/post.goGo
package schema

import (
	"entgo.io/contrib/entproto"
	"entgo.io/ent"
	"entgo.io/ent/schema"
	"entgo.io/ent/schema/edge"
	"entgo.io/ent/schema/field"
)

type Post struct{ ent.Schema }

func (Post) Annotations() []schema.Annotation { return []schema.Annotation{entproto.Message()} }

func (Post) Fields() []ent.Field {
	return []ent.Field{field.String("title").Annotations(entproto.Field(2))}
}

func (Post) Edges() []ent.Edge {
	return []ent.Edge{edge.From("author", User.Type).Ref("posts").Unique().Annotations(entproto.Field(3))}
}

Each field carries entproto.Field(n), the protobuf field number. This is the most important annotation. In protobuf the number, not the name, identifies a field on the wire. If you reorder fields or reuse a number after removing one, existing clients decode garbage without any error. entproto makes the number explicit and refuses to guess, and you should treat the numbers as write once. Number 1 is the id.

One generator run for both

Unlike plain Ent code generation, the extensions need a small program with entc.Generate and a list of extensions, kept out of the build by //go:build ignore:

ent/entc.goGo
//go:build ignore

package main

import (
	"log"

	"entgo.io/contrib/entoas"
	"entgo.io/contrib/entproto"
	"entgo.io/ent/entc"
	"entgo.io/ent/entc/gen"
	"github.com/ogen-go/ogen"
)

func main() {
	proto, err := entproto.NewExtension()
	if err != nil {
		log.Fatal(err)
	}
	spec := new(ogen.Spec)
	oas, err := entoas.NewExtension(entoas.Spec(spec))
	if err != nil {
		log.Fatal(err)
	}
	if err := entc.Generate("./schema", &gen.Config{}, entc.Extensions(proto, oas)); err != nil {
		log.Fatal(err)
	}
}
ent/generate.goGo
package ent

//go:generate go run -mod=mod entc.go

entproto.NewExtension() and entoas.NewExtension(entoas.Spec(spec)) both hook into the same code generation pass. The ogen.Spec value is the in memory OpenAPI document that entoas fills in and writes to ent/openapi.json.

Shell
set -e
(cd ent && GOFLAGS=-mod=mod go generate .)
go mod tidy > /dev/null 2>&1
echo "== the .proto Ent wrote"
cat ent/proto/entpb/entpb.proto
echo "== protoc accepts it"
protoc -I ent/proto --descriptor_set_out=/dev/null ent/proto/entpb/entpb.proto && echo ok
echo "== OpenAPI operations in ent/openapi.json"
python3 - <<'PY'
import json
spec = json.load(open("ent/openapi.json"))
for path, ops in spec["paths"].items():
    print(path, sorted(ops))
print("schemas:", sorted(spec["components"]["schemas"]))
PY
Text
== the .proto Ent wrote
// Code generated by entproto. DO NOT EDIT.
syntax = "proto3";

package entpb;

import "google/protobuf/empty.proto";

option go_package = "example.test/lab/ent/proto/entpb";

message Post {
  int64 id = 1;

  string title = 2;

  User author = 3;
}

message User {
  int64 id = 1;

  string name = 2;

  string email = 3;

  repeated Post posts = 4;
}

message CreateUserRequest {
  User user = 1;
}

message GetUserRequest {
  int64 id = 1;

  View view = 2;

  enum View {
    VIEW_UNSPECIFIED = 0;

    BASIC = 1;

    WITH_EDGE_IDS = 2;
  }
}

message ListUserRequest {
  int32 page_size = 1;

  string page_token = 2;

  View view = 3;

  enum View {
    VIEW_UNSPECIFIED = 0;

    BASIC = 1;

    WITH_EDGE_IDS = 2;
  }
}

message ListUserResponse {
  repeated User user_list = 1;

  string next_page_token = 2;
}

service UserService {
  rpc Create ( CreateUserRequest ) returns ( User );

  rpc Get ( GetUserRequest ) returns ( User );

  rpc List ( ListUserRequest ) returns ( ListUserResponse );
}
== protoc accepts it
ok
== OpenAPI operations in ent/openapi.json
/posts ['get', 'post']
/posts/{id} ['delete', 'get', 'patch']
/posts/{id}/author ['get']
/users ['get', 'post']
/users/{id} ['delete', 'get', 'patch']
/users/{id}/posts ['get']
schemas: ['Post', 'PostCreate', 'PostList', 'PostRead', 'PostUpdate', 'Post_AuthorRead', 'User', 'UserCreate', 'UserList', 'UserRead', 'UserUpdate', 'User_PostsList']
entpb/entpb.proto:6:1: warning: Import google/protobuf/empty.proto is unused.

The protobuf contract

The script prints the file entproto wrote, ent/proto/entpb/entpb.proto. The messages mirror the entities:

  • message Post { int64 id = 1; string title = 2; User author = 3; }
  • message User { int64 id = 1; string name = 2; string email = 3; repeated Post posts = 4; }

Edges are nested messages, so a User carries its posts and a Post its author. In the gRPC responses that nesting would be unbounded, so the generator adds views. GetUserRequest has a View enum with BASIC (the entity without edges) and WITH_EDGE_IDS (edges as ids). Ask for the basic view by default, and only for ids when the client needs them.

For the service, the request and response messages are generated from the method list:

  • CreateUserRequest { User user = 1; }
  • GetUserRequest { int64 id = 1; View view = 2; }
  • ListUserRequest { int32 page_size = 1; string page_token = 2; View view = 3; }
  • ListUserResponse { repeated User user_list = 1; string next_page_token = 2; }

and the service has three RPCs, Create, Get and List, because only those were selected with entproto.Methods(...). The list call uses token pagination, which is the right shape for an API clients walk to the end (keyset paging is what it maps to on the database).

protoc -I ent/proto --descriptor_set_out=/dev/null ent/proto/entpb/entpb.proto printed ok, so the file is valid protobuf, along with a warning: Import google/protobuf/empty.proto is unused. The generator imports empty.proto for methods that return nothing (a delete), and this service has none. The warning is harmless and a good example of the generator being a little general.

Turning the .proto into Go needs protoc with the Go plugins, and entproto adds its own plugin, protoc-gen-entgrpc, which writes the service implementation that calls the Ent client. I did not run those steps here. They are listed in the generated entpb/generate.go, and the plugins are installed with go install. The output is a server type you register with a gRPC server, with each method translating protobuf messages to Ent builders and mapping Ent errors to gRPC status codes (NotFound, AlreadyExists, InvalidArgument). You still write authentication, authorisation, logging and the server's lifecycle.

The OpenAPI contract

entoas wrote ent/openapi.json. The script lists its paths and methods:

  • /users: get, post
  • /users/{id}: delete, get, patch
  • /users/{id}/posts: get
  • /posts: get, post
  • /posts/{id}: delete, get, patch
  • /posts/{id}/author: get

and the component schemas: User, UserCreate, UserRead, UserUpdate, UserList, Post, PostCreate, PostRead, PostUpdate, PostList, Post_AuthorRead and User_PostsList. Separate schemas for create, read and update let the contract differ by operation: the create body has no id, the update body has every field optional. The edge paths (/users/{id}/posts) come from the posts edge.

The default exposes everything

Look at /users/{id} again. The User annotation asked for create, read and list only. The spec still contains delete and patch for users, and every operation for posts, which has no entoas annotation at all. The default policy is PolicyExpose: entoas generates every operation for every entity unless told to exclude it. Annotations like CreateOperation() configure an operation (its description, its fields) and do not narrow the set.

To invert the default, set entoas.DefaultPolicy(entoas.PolicyExclude) in the extension options. Then only the operations you annotate are generated. That is the safer posture for any API that will be reachable by clients you do not control, and it is the setting to choose before the first schema change that adds a sensitive entity. I did not regenerate with it in this lab.

Serving the OpenAPI spec

The spec is a contract, not a server. The Ent project provides ogent, an extension that generates a Go server from the document with the ogen library, with handlers that call the Ent client. The same trade as the gRPC server applies: you get CRUD handlers for free and write the cross cutting parts yourself. A smaller step is to use the spec for documentation and client generation only, and write the handlers by hand. I did not build ogent output here.

Authorisation

Neither extension adds authorisation. A generated DELETE /users/{id} deletes any user, for anyone who can reach it. The place to add rules is below the API, in the data layer, so they apply to the REST server, the gRPC server and everything else: Ent privacy rules filter queries and check mutations whatever generated them. Put the viewer in the request context in a gRPC interceptor or an HTTP middleware, and let the policy decide. Generated handlers pass the context through to Ent calls, which is why this works.

Keeping the contract stable

A generated contract changes whenever the schema does, so add controls:

  • Number protobuf fields explicitly and never reuse a number. Mark removed fields as reserved in a hand maintained copy, or never remove them.
  • Diff the generated contract in CI. Commit entpb.proto and openapi.json, regenerate in CI and fail on an unreviewed diff. A breaking change should be a decision, not an accident.
  • Run a compatibility tool (buf breaking, or an OpenAPI diff) against the previous version.
  • Exclude by default and add operations deliberately.
  • Do not expose internal fields (password hashes, tenant ids, soft delete markers). Both extensions can skip fields with annotations (entproto.SkipGen(), entoas.Skip(true)).

Installing and running it

The extensions live in one module, entgo.io/contrib, which holds entproto, entoas, entgql and their command line plugins. Add it to the module and the generator runs from the module cache like Ent itself:

Shell
go get entgo.io/contrib@latest
cd ent && go generate .

The lab pinned nothing for contrib and ogen and took the latest at the time of the run; in a real project pin both in go.mod and upgrade deliberately, because the generated contract can change between versions. protoc is a separate install (the lab used the Homebrew package). If you go on to generate Go code from the proto, you also need protoc-gen-go, protoc-gen-go-grpc and protoc-gen-entgrpc, each installed with go install at a pinned version. Keep that list in the repository README or a Makefile target, since a missing plugin produces an error that names the plugin and nothing about where to get it.

Testing the contract

A generated contract is still a contract, and it deserves tests at the seams:

  • Schema to proto. Check the generated file into the repository and diff it in CI. The diff is the review.
  • Proto compatibility. Run buf breaking against the previous commit (or an equivalent) so removing or renumbering a field fails the build.
  • Spec validity. Validate openapi.json with a linter before publishing. A broken spec breaks client generation downstream.
  • Behaviour. Call the generated server from a test with a real database and assert statuses, the way the chi and Ent API article asserts them. The gRPC equivalent uses an in process listener and a client.
  • Authorisation. Test that a viewer without rights gets NotFound or PermissionDenied from every generated operation, because new entities arrive with new operations by default.

A hand written contract on a generated core

The middle path that many teams settle on: write the .proto or OpenAPI by hand, shaped for clients, and implement it with handlers that call the Ent client. You lose the free CRUD and keep control of names, verbs and what is exposed. The Ent client is still doing the typed queries, the privacy rules still apply, and the contract can evolve independently of the table layout. When the entity model and the API are close, start generated and move individual endpoints to hand written ones as they diverge; when they differ from the start, begin by hand.

Pitfalls

Edges can make responses huge. A User message includes repeated Post posts. A client that asks for the full view of a user with ten thousand posts gets ten thousand posts. Keep the default view basic, cap list sizes and paginate edges separately.

Names leak. Entity and field names become message and path names. Renaming a Go field to tidy the code is a breaking API change if the proto or spec follows it. Annotate names explicitly where they matter.

Timestamps and enums. Time fields become protobuf Timestamp messages and enums become protobuf enums with a zero value for unspecified. Adding an enum value in the schema adds one to the contract, which old clients see as an unknown number. Decide how clients should treat values they do not know.

Transactions do not cross the wire. A unit of work that needs several entities written atomically is one RPC with a richer message, not several generated calls.

When it fits

It fits an internal CRUD service where the entity model is the API, a prototype that needs a working contract on day one, and a team that wants the schema to be the single source of truth. It fits less well for a public API whose shape must outlive the database, or where operations are verbs (a payment capture, a state transition) rather than entity updates. Those need hand designed contracts, with the generated ones as a reference.

Checklist

  • Number every protobuf field explicitly, and never change a number.
  • Switch entoas to an exclude policy and add operations on purpose.
  • Validate the .proto with protoc and diff the contracts in CI.
  • Put authorisation in Ent privacy rules, not in the transport.
  • Keep sensitive fields out with skip annotations.
  • Treat generated servers as scaffolding for the handlers, and write the cross cutting concerns yourself.

The extensions are documented in the entproto guide and the entoas guide.

Also inAPIs
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.