All writing

From NestJS to Go, my first two months without a framework holding my hand

8 min read

In this post

I wrote the NowCheckin backend in NestJS, then joined Vinova and started writing Go for a microservices platform. These are my notes after a little over two months, on what annoyed me, what I got used to and what I still miss.

I wrote the NowCheckin backend in NestJS. In August I joined Vinova to work on a microservices platform, and the language there is Go. That was a little over two months ago.

To be clear up front: this is not a benchmark, and I am not going to tell you which one is better. Two months is not enough for me to teach anyone Go. It is just enough that I still remember exactly how it feels to have moved house, and I want to write that down before I get used to it and forget.

The code samples are ones I wrote for this post. They are not from the project.

In my first week I went looking for things that do not exist

On day one my reflex was to look for decorators. There are none. Then I looked for the container that injects services. Not there either. When I needed to handle an error I typed try and watched the editor turn red.

At the time I thought Go was simply missing things. It took until about week three to see it differently: nothing is missing, it is left out on purpose. NestJS hides the boring parts so I can write quickly. Go makes me write them out so that whoever reads the code later can see them.

That sounds like a slogan. Code makes it clearer.

The same endpoint, written twice

Fetch a policy by id, return 404 if it is not there.

NestJS

@Controller('policies')
export class PolicyController {
  constructor(private readonly policies: PolicyService) {}

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.policies.findOne(id);
  }
}

Go

func (h *PolicyHandler) FindOne(w http.ResponseWriter, r *http.Request) {
	policy, err := h.policies.FindOne(r.Context(), r.PathValue("id"))
	if errors.Is(err, ErrNotFound) {
		http.Error(w, "policy not found", http.StatusNotFound)
		return
	}
	if err != nil {
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	json.NewEncoder(w).Encode(policy)
}

The NestJS version is half the length, and when I was building NowCheckin I loved that. But ask one question: if the policy is not found, what does the client get?

With the NestJS version I cannot answer by looking at the code above. I have to open PolicyService and check whether it does throw new NotFoundException(). And if the service throws something unexpected that nobody catches, the default NestJS filter returns exactly this:

{
  "statusCode": 500,
  "message": "Internal server error"
}

The Go version is long and a little tiring to look at, but every path a request can take is right there. Not found is 404. Anything else is 500. Done.

One aside: that r.PathValue("id") is fairly new. Since Go 1.22 the router in the standard library understands methods and path parameters, so plenty of small services need no framework at all.

if err != nil, for the hundredth time

This annoyed me more than anything else in the first month. Now it is the thing I trust most.

In NestJS an error is an exception. It flies up through the layers until something catches it. Convenient, but it also means that when I read a function, I do not know what it might throw.

In Go an error is an ordinary value that a function returns. Whoever called it has to decide on the spot: handle it, or add context and pass it up.

policy, err := s.repo.FindByID(ctx, id)
if err != nil {
	return nil, fmt.Errorf("find policy %s: %w", id, err)
}

The %w keeps the original error inside, so higher up I can still use errors.Is to ask "is this a not-found error". The result is that when something breaks, the log line reads like a story with a beginning and an end, something like find policy 123: query: connection refused, where I used to get a stack trace three screens long.

I am not going to claim that writing if err != nil is fun. The hundredth time is as dull as the first. But since I switched I have not met the "the server returned 500 and nobody knows where it came from" bug again.

With no container, who wires things together?

The main function does.

func main() {
	db := mustOpenDB(cfg.DatabaseURL)

	policyRepo := postgres.NewPolicyRepo(db)
	policyService := policy.NewService(policyRepo)
	policyHandler := httpapi.NewPolicyHandler(policyService)

	mux := http.NewServeMux()
	mux.HandleFunc("GET /policies/{id}", policyHandler.FindOne)

	log.Fatal(http.ListenAndServe(":8080", mux))
}

The first time I saw this I was a little shocked, because it is exactly what NestJS had been doing quietly for me, except now I type it. Whatever a service needs gets passed straight into its constructor.

The downside is that main grows as the project grows. The upside is that when I want to know where something is created, "go to definition" takes me there. No tracing which module imports which, or which provider is exported.

Goroutines, which I thought I understood

Start two things at once and wait for both:

NestJS

const [customer, claims] = await Promise.all([
  this.customers.find(id),
  this.claims.listFor(id)
]);

Go

g, ctx := errgroup.WithContext(ctx)

g.Go(func() (err error) {
	customer, err = s.customers.Find(ctx, id)
	return
})
g.Go(func() (err error) {
	claims, err = s.claims.ListFor(ctx, id)
	return
})

if err := g.Wait(); err != nil {
	return nil, err
}

For brevity NestJS wins, no argument.

But there are two differences that took me a while to feel. First, my JavaScript runs on a single thread. Promise.all lets me wait on several I/O calls at once, but it cannot make two heavy calculations run in parallel. Goroutines really do run in parallel across cores.

Second, cancellation. With Promise.all, if the first call fails the second still runs to the end and its result is thrown away. With errgroup, when the first fails ctx is cancelled and the second knows to stop.

In return Go handed me a kind of bug I rarely had to think about before: two goroutines writing to the same variable. I hit it once and lost an afternoon. The lesson was to always run tests with the -race flag.

What I miss from NestJS

To be fair, there are things I really do miss.

  • ValidationPipe and DTOs. Declare a class, add a few decorators, and badly shaped requests are stopped at the door. In Go I assemble libraries and write more of it myself.
  • Swagger almost for free. In Go I either write the spec first and generate code, or write comments in exactly the right syntax.
  • Guards, interceptors, pipes. A clear place for each kind of work. In Go it is all middleware, and the conventions are mine to set.
  • A CLI that scaffolds a module. One command gives me the frame, and I do not have to think about where files go.

What I would not give back

  • One binary. Small image, near-instant startup. On Kubernetes that is worth real money.
  • Fast builds and tests. Fast enough that I lost the habit of getting up to make coffee while I wait.
  • gofmt. Nobody on the team argues about semicolons or line breaks.
  • Reading other people's code. I only need to know the language, not a framework on top. In my first week I could read someone else's service, which a newcomer to a NestJS project would struggle to do.

All of it in one table

NestJSGo
Starting a new serviceVery fast, the frame is thereSlower, you wire it yourself
Reading someone else's codeYou need to know the frameworkYou need to know the language
ErrorsExceptions, caught in a filterReturned values, handled in place
Validation and API docsNearly built inAssembled by hand
CPU-heavy workBlocks the event loopGoroutines run in parallel
Startup, memory, image sizeHeavierVery light
Signature bugundefined where you least expect itnil pointers, data races

What about speed?

Someone will ask. I have not measured it myself, so I will not pronounce on it. I did read a benchmark of NestJS with Prisma against Go with GORM on the same PostgreSQL table: at moderate load the difference was only about 10%. For an ordinary CRUD API, most of the time is spent in the database and not in the language.

My view is that "it is faster" is not enough of a reason on its own to move to Go.

If I rebuilt NowCheckin, would I choose Go?

No. I would still choose NestJS.

NowCheckin is a new product that had to launch early, with a lot of CRUD and a lot of third-party integrations. There, the most valuable thing is how fast you can build features, and NestJS gives exactly that.

Where I work now is different: many small services, many people reading and changing each other's code, all running on Kubernetes. Go fits that far better. It is slower on day one and faster on day one hundred.

I am only at day sixty-something, so if I have misunderstood something, I would be glad to hear it. Thanks for reading.

Further reading