A registration endpoint is small enough to hold in your head and real enough to hurt: validate the input, hash the password, write to the database, send a welcome email. In Express, I pulled the business logic out of the route handler — the logic in its own registerUser function, and a tiny handler whose only job is to translate HTTP into that logic and back. The result: a handler you can read at a glance, and a business rule you can test without ever starting a server. (The full before/after lives in the starter repo linked at the bottom — grab it if you want to run everything yourself.)
Today I’m changing the language, not the subject. I take that same endpoint and set it next to its Go equivalent. Same gesture, same split: the logic on one side, the HTTP handler on the other.
No sleight of hand, no framework hiding behind the curtain. Just the code, side by side. The contrasts come after — today, we just look.
1. The Express version, for reference
Same shape as before: the logic lives in registerUser, and a thin handler only translates.
const bcrypt = require('bcrypt')
const { PrismaClient } = require('@prisma/client')
const { sendWelcomeEmail } = require('./mailer')
const prisma = new PrismaClient()
class ValidationError extends Error {}
class ConflictError extends Error {}
// Business logic, decoupled from the framework: it knows nothing about
// req/res. You can call it from a script, a test, a job — anywhere.
async function registerUser({ email, password }) {
if (!email || !email.includes('@')) {
throw new ValidationError('Invalid email')
}
if (!password || password.length < 8) {
throw new ValidationError('Password too short (8 characters minimum)')
}
const existing = await prisma.user.findUnique({ where: { email } })
if (existing) {
throw new ConflictError('This email is already taken')
}
const passwordHash = await bcrypt.hash(password, 12)
const user = await prisma.user.create({ data: { email, passwordHash } })
try {
await sendWelcomeEmail(user.email)
} catch (err) {
console.error('Failed to send email', err)
}
return { id: user.id, email: user.email }
}
module.exports = { registerUser, ValidationError, ConflictError }
Enter fullscreen mode Exit fullscreen mode
const express = require('express')
const { registerUser, ValidationError, ConflictError } = require('./registerUser')
const app = express()
app.use(express.json())
// The handler is thin: read the request, call the business logic,
// translate the result (or a business error) into an HTTP response.
app.post('/register', async (req, res, next) => {
try {
const user = await registerUser(req.body)
return res.status(201).json(user)
} catch (err) {
if (err instanceof ValidationError) return res.status(400).json({ error: err.message })
if (err instanceof ConflictError) return res.status(409).json({ error: err.message })
return next(err)
}
})
module.exports = app
Enter fullscreen mode Exit fullscreen mode
2. The same endpoint, in Go
Here’s the same endpoint, structured the same way, in Go: the logic on one side, the handler on the other.
One difference in layers I want to name right away, so you don’t mistake it for magic: on the Express side, the database went through Prisma, an ORM. On the Go side, I’m showing plain SQL with database/sql — the idiomatic default in the language. It’s a subject of its own, and I’ll come back to it another day.
RegisterUser is the Go mirror of registerUser: the business logic, decoupled, with no knowledge of net/http.
package main
import (
"database/sql"
"errors"
"fmt"
"log"
"strings"
"golang.org/x/crypto/bcrypt"
)
// Business errors — the Go equivalent of ValidationError / ConflictError.
// They never mention an HTTP status: the logic doesn't know HTTP exists.
var (
ErrValidation = errors.New("invalid input")
ErrConflict = errors.New("this email is already taken")
)
// RegisterInput describes, explicitly, the shape of the expected data.
type RegisterInput struct {
Email string
Password string
}
// RegisteredUser describes what the function returns.
type RegisteredUser struct {
ID string
Email string
}
// RegisterUser holds the business logic. It knows nothing about
// *http.Request or http.ResponseWriter: call it from a script, a test, a job.
func RegisterUser(db *sql.DB, in RegisterInput) (RegisteredUser, error) {
if !strings.Contains(in.Email, "@") {
return RegisteredUser{}, fmt.Errorf("%w: invalid email", ErrValidation)
}
if len(in.Password) < 8 {
return RegisteredUser{}, fmt.Errorf("%w: password too short (8 characters minimum)", ErrValidation)
}
var exists bool
if err := db.QueryRow(
"SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)", in.Email,
).Scan(&exists); err != nil {
return RegisteredUser{}, err
}
if exists {
return RegisteredUser{}, ErrConflict
}
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), 12)
if err != nil {
return RegisteredUser{}, err
}
var id string
if err := db.QueryRow(
"INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id",
in.Email, string(hash),
).Scan(&id); err != nil {
return RegisteredUser{}, err
}
if err := sendWelcomeEmail(in.Email); err != nil {
// Sending the email must not fail the registration.
log.Printf("failed to send email: %v", err)
}
return RegisteredUser{ID: id, Email: in.Email}, nil
}
Enter fullscreen mode Exit fullscreen mode
If you’ve never read Go, four landmarks are enough to keep you from getting lost in this file:
-
A function can return several values. Look at the signature:
func RegisterUser(db *sql.DB, in RegisterInput) (RegisteredUser, error). It hands back two things at once — the result and an error. That’s the Go convention: you often return(result, error), and the caller checks the error right after. Nothing more to grasp here; I’ll cover it in depth in the next piece. -
nilis the absence of a value. The mental equivalent of thenullyou know. Adbset tonilmeans “no connection.” -
:=declares and assigns at once (hash, err := ...), wherevaronly declares (var id string). Two ways to introduce a variable, depending on whether you already have a value to put in it. -
The initial capital decides visibility.
RegisterUserstarts with a capital: it’s exported, visible from outside the package.sendWelcomeEmail, lowercase, stays private to the package. The language reads the case like a scope keyword.
With those four landmarks, the rest reads like pseudo-code.
The handler next — thin, only relaying HTTP to the business logic, exactly like the Express side.
package main
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
)
// registerHandler is thin: decode, call RegisterUser, translate to HTTP.
// It is an HTTP<->domain translator, not a place where logic lives.
func registerHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var in RegisterInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
user, err := RegisterUser(db, in)
switch {
case err == nil:
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(user)
case errors.Is(err, ErrValidation):
http.Error(w, err.Error(), http.StatusBadRequest)
case errors.Is(err, ErrConflict):
http.Error(w, err.Error(), http.StatusConflict)
default:
http.Error(w, "server error", http.StatusInternalServerError)
}
}
}
Enter fullscreen mode Exit fullscreen mode
And the wiring: a http.ServeMux from the standard library, mux.HandleFunc("POST /register", ...). No third-party router, no framework — just the stdlib.
package main
import (
"database/sql"
"log"
"net/http"
_ "github.com/jackc/pgx/v5/stdlib"
)
func main() {
db, err := sql.Open("pgx", "postgres://user:pass@localhost:5432/register")
if err != nil {
log.Fatal(err)
}
defer db.Close()
mux := http.NewServeMux()
mux.HandleFunc("POST /register", registerHandler(db))
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
Enter fullscreen mode Exit fullscreen mode
One detail in passing: sendWelcomeEmail is a stub here, the same way mailer.js was on the Express side — nothing to dwell on.
3. What jumps out
Now that we have both versions side by side, a few contrasts show up right away. No need to dig — they’re right on the surface.
First, the types. In Go, RegisterInput and RegisteredUser are declared, in black and white, at the top of the file. The shape of the expected data isn’t a tacit convention or a comment — it’s written down, and the compiler enforces it. On the Express side, req.body is a dynamic object: its shape exists in your head, maybe in a comment, but nowhere does the language check it for you. That’s what I like here: I don’t have to guess what RegisterUser wants — the signature tells me.
Next, the errors: if err != nil everywhere, an error you return instead of throwing. I like seeing every possible failure written right there, in the flow — but it’s a big topic, and it’s the whole subject of the next deep dive in this series.
Something else that’s missing: a framework. No Express, no equivalent. net/http comes from the standard library, and the POST /register routing you saw in main.go has been built in since recent versions of Go — zero HTTP dependencies to install. That changes the nature of the project: the router is nobody’s business but the stdlib’s. And there’s a flip side I appreciate: no framework version to track, no breaking change to brace for at every upgrade.
Last point, the plain SQL with database/sql: the queries are spelled out in full inside RegisterUser, not tucked away behind an ORM. I’ll admit I like seeing exactly what goes to the database, with no layer to guess at — but that too is a big subject of its own, for another day.
4. The proof it runs
Here’s the test from the Express side, restated as a question: can you test this rule without starting an HTTP server? On the Express side the answer turned out to be yes, once the logic was out of the handler. Let’s see what it looks like in Go.
package main
import (
"errors"
"testing"
)
// Validation happens before any DB access: no database and no server needed.
func TestRegisterUser_RejectsShortPassword(t *testing.T) {
_, err := RegisterUser(nil, RegisterInput{Email: "[email protected]", Password: "123"})
if !errors.Is(err, ErrValidation) {
t.Fatalf("expected ErrValidation, got: %v", err)
}
}
Enter fullscreen mode Exit fullscreen mode
Look at what doesn’t happen. go test, and that’s it: no server starting up, no database to spin up. The first argument passed to RegisterUser is literally nil — no connection, no mock, nothing. And it works, because the email and password validation fires before the code ever touches db. The business rule is checked in a few milliseconds, and it can fail for one reason only: itself.
It’s the same demonstration as on the Express side, with node:test on registerUser. Change the language, keep the gesture: decouple the logic from the framework, and the test becomes trivial to write.
5. What’s next
One thread was left deliberately taut in this piece: if err != nil, everywhere, at every call that can fail. A pleasure or a chore? That’s the whole subject of the next deep dive.
If I had to sum up the feeling of this comparison in one sentence: on the Go side, everything is written down, nothing is hidden. The types, the errors, the SQL queries, the routing — it’s all there, in front of you, in the file. At first it throws you a little; there are more lines to read. After a while, I find it restful: I never have to wonder what’s happening behind a decorator or an ORM, because the answer is always in the file open in front of me.
The series continues.
Want to run the whole thing yourself? The full journey — Express before/after plus this Go version, both runnable from a clean clone — lives in the free node-to-go-starter repo, with a one-page Express→Go cheat sheet. Grab it here: https://github.com/bu7ch/node-to-go-starter.
답글 남기기