Go Building Services — HTTP, Signals, Migrations and Reflection
The previous tutorials covered Go’s type system, concurrency primitives, and error handling. This one shifts to production patterns you need when building real services: serving HTTP requests, managing process lifecycle with signal handling, running database migrations with embedded files, and using reflection for metaprogramming. Each topic stands on its own, but together they represent the kind of infrastructure code that appears in nearly every Go service.
net/http
Go’s standard library includes a full HTTP server and client in net/http. There are no frameworks to install for basic web services — the standard library handles routing, request parsing, response writing, and even test servers.
HTTP Handler Basics
An HTTP handler in Go is any function with the signature func(http.ResponseWriter, *http.Request). The ResponseWriter is where you write the response, and the *Request carries everything about the incoming request — method, URL, headers, body. To use a plain function as a handler, wrap it with http.HandlerFunc:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func helloHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, %s %s!", r.Method, r.URL.Path)
}
func main() {
ts := httptest.NewServer(http.HandlerFunc(helloHandler))
defer ts.Close()
resp, err := http.Get(ts.URL + "/world")
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Printf(" status: %s\n", resp.Status)
fmt.Printf(" body: %s\n", string(body))
}
httptest.NewServer starts a real HTTP server on a random port and returns a test server whose URL field gives you the base address. This is the standard way to test HTTP handlers without binding to a fixed port. The handler receives a GET request for /world and writes back Hello, GET /world!.
ServeMux Routing
For services with multiple endpoints, http.NewServeMux provides a router that maps URL patterns to handlers. Go 1.22 introduced method-based patterns, so you can write "GET /health" or "POST /echo" directly in the pattern string:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "ok")
})
mux.HandleFunc("GET /greet", func(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" {
name = "stranger"
}
fmt.Fprintf(w, "Hello, %s!", name)
})
mux.HandleFunc("POST /echo", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
w.Header().Set("Content-Type", "text/plain")
w.Write(body)
})
ts := httptest.NewServer(mux)
defer ts.Close()
The /health endpoint returns a simple status check. The /greet endpoint reads a query parameter with r.URL.Query().Get("name"). The /echo endpoint reads the request body with io.ReadAll(r.Body) and writes it back. Each route is restricted to a specific HTTP method — a POST to /health would return a 405 Method Not Allowed.
JSON API
Most services exchange JSON. The pattern is straightforward: define request and response structs with json tags, decode the request body with json.NewDecoder, and encode the response with json.NewEncoder:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
type MathRequest struct {
A float64 `json:"a"`
B float64 `json:"b"`
Op string `json:"op"`
}
type MathResponse struct {
Result float64 `json:"result"`
Error string `json:"error,omitempty"`
}
func mathHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
var req MathRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(MathResponse{Error: "invalid JSON"})
return
}
var result float64
switch req.Op {
case "add":
result = req.A + req.B
case "sub":
result = req.A - req.B
case "mul":
result = req.A * req.B
case "div":
if req.B == 0 {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(MathResponse{Error: "division by zero"})
return
}
result = req.A / req.B
default:
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(MathResponse{Error: "unknown op: " + req.Op})
return
}
json.NewEncoder(w).Encode(MathResponse{Result: result})
}
The handler sets Content-Type: application/json first, then decodes the request body into a MathRequest. If decoding fails or the operation is invalid, it writes an error response with an appropriate status code and returns early. On success, it encodes the result. The omitempty tag on the Error field means it is omitted from the JSON output when empty, so successful responses only contain the result field.
Middleware
Middleware is a function that wraps an http.Handler and adds behavior — logging, authentication, rate limiting — without changing the handler itself. The pattern is: take a handler in, return a handler out. To capture the response status code, you need a wrapper around ResponseWriter that intercepts the WriteHeader call:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
type statusRecorder struct {
http.ResponseWriter
status int
}
func (sr *statusRecorder) WriteHeader(code int) {
sr.status = code
sr.ResponseWriter.WriteHeader(code)
}
type logEntry struct {
method string
path string
status int
}
func loggingMiddleware(logs *[]logEntry, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
*logs = append(*logs, logEntry{
method: r.Method,
path: r.URL.Path,
status: rec.status,
})
})
}
statusRecorder embeds http.ResponseWriter so it satisfies the interface automatically. It overrides only WriteHeader to capture the status code before forwarding the call. The loggingMiddleware function wraps any handler: it creates a recorder, passes it to the inner handler via ServeHTTP, and after the handler returns, records the method, path, and status. Because the signature is func(http.Handler) http.Handler, middleware composes naturally — you can stack multiple layers by wrapping one around another.
HTTP Client
Go’s net/http package also provides a client. For simple requests, http.Get and http.Post work directly. When you need custom headers or methods, build an *http.Request with http.NewRequest and execute it with a client.Do call:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Simple GET
resp, _ := http.Get(ts.URL + "/simple")
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
// Simple POST
resp, _ = http.Post(ts.URL+"/data", "text/plain", bytes.NewBufferString("payload"))
body, _ = io.ReadAll(resp.Body)
resp.Body.Close()
// Custom headers with NewRequest + client.Do
req, _ := http.NewRequest("GET", ts.URL+"/custom", nil)
req.Header.Set("User-Agent", "GoLearn/1.0")
req.Header.Set("X-Custom", "my-value")
client := &http.Client{}
resp, _ = client.Do(req)
body, _ = io.ReadAll(resp.Body)
resp.Body.Close()
http.Get and http.Post are convenience wrappers that use the default client. For anything beyond trivial requests — setting headers, configuring timeouts, controlling redirects — create an *http.Request explicitly and pass it to client.Do. Always close resp.Body when you are done reading, typically with defer resp.Body.Close(), to avoid leaking connections.
Signal Handling and Graceful Shutdown
When a service receives a termination signal (Ctrl+C sends SIGINT, container orchestrators send SIGTERM), it should not just crash. It should stop accepting new work, finish in-flight operations, flush buffers, close connections, and then exit. Go makes this possible with os/signal, channels, and context cancellation.
The Shutdown Pattern
The standard approach is: create a cancellable context, listen for signals on a buffered channel, cancel the context when a signal arrives, and use a WaitGroup to wait for goroutines to finish their cleanup.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
go func() {
sig := <-sigCh
fmt.Printf("\n [signal] received %v, initiating shutdown...\n", sig)
cancel()
}()
proc := NewProcessor()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
defer func() {
proc.Flush()
fmt.Println(" [worker] cleanup complete")
}()
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
flushTicker := time.NewTicker(2 * time.Second)
defer flushTicker.Stop()
items := []string{
"INSERT users alice",
"UPDATE users bob",
"INSERT orders ord-1",
"DELETE sessions expired",
"INSERT events login",
}
idx := 0
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if idx < len(items) {
proc.Process(items[idx])
idx++
} else {
idx = 0
}
case <-flushTicker.C:
proc.Flush()
}
}
}()
wg.Wait()
fmt.Println(" [main] all goroutines stopped, exiting cleanly")
}
There are several details worth noting here. The signal channel has a buffer of 1 — make(chan os.Signal, 1). This is important because the signal delivery is non-blocking: if the channel is full, the signal is dropped. A buffer of 1 ensures the signal is captured even if the goroutine is momentarily busy. signal.Notify registers the channel to receive os.Interrupt (Ctrl+C) and syscall.SIGTERM (the standard termination signal from process managers).
The signal-handling goroutine blocks on <-sigCh until a signal arrives, then calls cancel(). This cancels the context, which causes ctx.Done() to close in every goroutine that is selecting on it. The worker goroutine sees the cancellation, returns from the loop, and its deferred functions run in LIFO order — first flushTicker.Stop(), then ticker.Stop(), then proc.Flush(), and finally wg.Done().
The Processor type buffers work items and flushes them either periodically or during shutdown:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
type Processor struct {
buffer []string
}
func NewProcessor() *Processor {
return &Processor{buffer: make([]string, 0, 100)}
}
func (p *Processor) Process(item string) {
p.buffer = append(p.buffer, item)
}
func (p *Processor) Flush() {
if len(p.buffer) == 0 {
return
}
fmt.Printf(" [processor] flushing %d items\n", len(p.buffer))
for _, item := range p.buffer {
fmt.Printf(" -> %s\n", item)
}
p.buffer = p.buffer[:0]
}
The Flush method resets the buffer with p.buffer[:0], which keeps the underlying array allocated but sets the length to zero. This pattern avoids allocating a new slice on every flush. The key guarantee of this architecture is that no data is silently lost — the deferred Flush call in the worker ensures that any items buffered between the last periodic flush and the shutdown signal are still written out before the process exits.
Database Migrations
Production services need to evolve their database schema over time — adding tables, altering columns, creating indexes. Migration tools track which changes have been applied and run only the new ones. In Go, the go:embed directive lets you compile migration files directly into the binary, and goose provides the migration engine.
go:embed and embed.FS
The go:embed directive is a compiler instruction that bakes file contents into the binary at build time. The variable it annotates becomes an embed.FS — a read-only filesystem that you can read from at runtime without any external files:
1
2
3
4
import "embed"
//go:embed migrations/*.sql
var migrationFS embed.FS
The comment //go:embed migrations/*.sql must appear directly above the variable declaration with no blank line in between. It tells the compiler to find every .sql file in the migrations/ directory and embed their contents. At runtime, migrationFS is a fully functional read-only filesystem — you can list directories, read files, and pass it to any library that accepts an fs.FS:
1
2
3
4
5
6
7
func part1_whatIsEmbed() {
entries, _ := migrationFS.ReadDir("migrations")
for _, e := range entries {
data, _ := migrationFS.ReadFile("migrations/" + e.Name())
fmt.Printf(" %-30s (%d bytes)\n", e.Name(), len(data))
}
}
This prints the name and size of each embedded SQL file. The files exist inside the compiled binary — if you delete the migrations/ directory after building, the binary still works. This is particularly useful for deployment: your migration files ship with the binary rather than needing to be copied alongside it.
Migration Files
Each migration file contains a -- +goose Up section with the forward change and a -- +goose Down section with the rollback. Goose uses these annotations to know which SQL to run in each direction:
1
2
3
4
5
6
7
8
9
10
-- +goose Up
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ DEFAULT now()
);
-- +goose Down
DROP TABLE IF EXISTS users;
A second migration adds a column to the existing table:
1
2
3
4
5
-- +goose Up
ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'member';
-- +goose Down
ALTER TABLE users DROP COLUMN IF EXISTS role;
Migrations are numbered sequentially. Goose runs them in order and records the current version in a goose_db_version table so it knows which migrations have already been applied.
Running Migrations with Goose
The workflow has four steps: open a database connection, point goose at the embedded filesystem, set the SQL dialect, and call Up to apply pending migrations:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import (
"database/sql"
"embed"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/pressly/goose/v3"
)
//go:embed migrations/*.sql
var migrationFS embed.FS
func part2_runMigrations() {
db, err := sql.Open("pgx", dbDSN)
if err != nil {
log.Fatalf("open db: %v", err)
}
defer db.Close()
goose.SetBaseFS(migrationFS)
if err := goose.SetDialect("postgres"); err != nil {
log.Fatalf("set dialect: %v", err)
}
if err := goose.Up(db, "migrations"); err != nil {
log.Fatalf("migration failed: %v", err)
}
}
goose.SetBaseFS(migrationFS) tells goose to read migration files from the embedded filesystem instead of disk. goose.SetDialect("postgres") configures the SQL generation for PostgreSQL. goose.Up(db, "migrations") scans the migrations directory, compares the file versions against the goose_db_version table, and runs any that have not been applied yet. If all migrations are already applied, it does nothing.
The blank import _ "github.com/jackc/pgx/v5/stdlib" registers the pgx driver with database/sql so that sql.Open("pgx", dsn) works. This is a common Go pattern: importing a package solely for its init function’s side effects.
To check the current version after migrations have run:
1
2
3
4
5
6
7
8
9
10
func part3_checkVersion() {
db, _ := sql.Open("pgx", dbDSN)
defer db.Close()
version, err := goose.GetDBVersion(db)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Current version: %d\n", version)
}
goose.GetDBVersion returns the highest migration version that has been applied. After running both migration files, this returns 2. To add a new migration, you create 003_something.sql in the migrations/ directory and rebuild. The next call to goose.Up applies only the new file.
Reflection
Go is a statically typed language, but sometimes you need to inspect or manipulate types at runtime. The reflect package provides this capability. Libraries like encoding/json, database/sql, and fmt all use reflection internally to work with arbitrary types. Understanding reflection helps you read those libraries and write generic utilities of your own.
reflect.TypeOf and reflect.ValueOf
The two entry points to reflection are reflect.TypeOf, which returns the type of a value, and reflect.ValueOf, which returns a reflect.Value wrapping the actual data. Every reflected value has a Kind (the broad category: int, string, struct, slice, ptr) and a Type (the specific type: main.Employee, []int, *main.Employee):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
i := 42
s := "hello"
f := 3.14
sl := []int{1, 2, 3}
m := map[string]int{"a": 1, "b": 2}
e := Employee{Name: "Alice", Age: 30, Department: "Engineering", Salary: 120000}
p := &e
values := []interface{}{i, s, f, sl, m, e, p}
labels := []string{"int", "string", "float64", "slice", "map", "struct", "pointer"}
for idx, v := range values {
t := reflect.TypeOf(v)
rv := reflect.ValueOf(v)
fmt.Printf(" %-8s Kind=%-10s Type=%-28s Value=%v\n",
labels[idx], t.Kind(), t, rv)
}
The distinction between Kind and Type matters. A Kind of struct tells you the value is some struct, while the Type tells you it is specifically main.Employee. A pointer to that struct has Kind of ptr and Type of *main.Employee. This distinction is how generic code decides what operations are valid — you check Kind to branch on the category, then use Type for specifics.
Inspecting Structs and Tags
Reflection can iterate over a struct’s fields, read their types, check whether they are exported, and extract struct tags. This is exactly how encoding/json decides which fields to include and what JSON key names to use:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
type Employee struct {
Name string `json:"name"`
Age int `json:"age,omitempty"`
Department string `json:"department"`
Salary float64 `json:"salary,omitempty"`
active bool
}
e := Employee{
Name: "Bob", Age: 25, Department: "Marketing", Salary: 85000, active: true,
}
t := reflect.TypeOf(e)
v := reflect.ValueOf(e)
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
val := v.Field(i)
exported := field.IsExported()
jsonTag := field.Tag.Get("json")
valStr := ""
if exported {
valStr = fmt.Sprintf("%v", val.Interface())
} else {
valStr = "(unexported)"
}
fmt.Printf(" %-12s %-10s exported=%-5t tag=%q value=%s\n",
field.Name, field.Type, exported, jsonTag, valStr)
}
t.NumField() returns the number of fields. t.Field(i) returns a reflect.StructField with the field’s name, type, tag string, and export status. field.Tag.Get("json") extracts the value of the json key from the struct tag. The unexported active field is visible to reflection — you can see its name and type — but calling Interface() on its value will panic. The CanInterface() check (or IsExported() on the field descriptor) tells you whether it is safe to read the value.
Modifying Values
Reflection can also modify values, but only if you pass a pointer. reflect.ValueOf(x) gives you a read-only copy. To get a settable value, you need reflect.ValueOf(&x).Elem() — the Elem() call dereferences the pointer, giving reflection access to the original variable:
1
2
3
4
5
6
7
8
9
x := 10
rv := reflect.ValueOf(&x).Elem()
fmt.Printf(" CanSet(): %t\n", rv.CanSet()) // true
rv.SetInt(42)
fmt.Printf(" x after SetInt(42): %d\n", x) // 42
// Without pointer — CanSet() returns false
rvNoPtr := reflect.ValueOf(x)
fmt.Printf(" CanSet(): %t\n", rvNoPtr.CanSet()) // false
The same principle applies to struct fields. Pass a pointer to the struct, call Elem(), then use FieldByName to find and modify individual fields:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
e := Employee{Name: "Charlie", Age: 28, Department: "Sales", Salary: 70000}
rv := reflect.ValueOf(&e).Elem()
nameField := rv.FieldByName("Name")
if nameField.IsValid() && nameField.CanSet() {
nameField.SetString("Diana")
}
salaryField := rv.FieldByName("Salary")
if salaryField.IsValid() && salaryField.CanSet() {
salaryField.SetFloat(95000)
}
fmt.Printf(" after: %+v\n", e) // {Name:Diana Age:28 Department:Sales Salary:95000 active:false}
Unexported fields cannot be set even with a pointer — CanSet() returns false for them. Go enforces its visibility rules through reflection, not just at compile time. Always check IsValid() (the field exists) and CanSet() (the field is settable) before calling a setter.
Dynamic Function Calls
Reflection can inspect function signatures and call functions dynamically. reflect.ValueOf(fn) wraps the function, and Call invokes it with a slice of reflect.Value arguments:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func add(a, b int) int { return a + b }
func greet(name string, excited bool) string {
if excited {
return "Hello, " + name + "!"
}
return "Hello, " + name + "."
}
addVal := reflect.ValueOf(add)
addType := addVal.Type()
fmt.Printf(" NumIn: %d, NumOut: %d\n", addType.NumIn(), addType.NumOut())
for i := 0; i < addType.NumIn(); i++ {
fmt.Printf(" Arg[%d]: %s\n", i, addType.In(i))
}
args := []reflect.Value{reflect.ValueOf(3), reflect.ValueOf(4)}
results := addVal.Call(args)
fmt.Printf(" add(3, 4) = %v\n", results[0].Interface()) // 7
greetArgs := []reflect.Value{reflect.ValueOf("Go"), reflect.ValueOf(true)}
greetResults := reflect.ValueOf(greet).Call(greetArgs)
fmt.Printf(" greet(\"Go\", true) = %q\n", greetResults[0].Interface()) // "Hello, Go!"
NumIn() and NumOut() tell you how many parameters and return values the function has. In(i) gives the type of each parameter. Call takes a []reflect.Value and returns a []reflect.Value. Each argument must match the expected type exactly — passing a float64 where an int is expected will panic. This is the mechanism that RPC frameworks and dependency injection containers use to call functions they only know about at runtime.
Practical Example: PrintTable
To see reflection applied to a real problem, here is PrintTable — a function that takes any slice of structs and prints it as a formatted table. It does not know the struct type at compile time; it discovers the field names and values at runtime:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
func PrintTable(slice interface{}) {
v := reflect.ValueOf(slice)
if v.Kind() != reflect.Slice {
return
}
if v.Len() == 0 {
return
}
elemType := v.Type().Elem()
if elemType.Kind() != reflect.Struct {
return
}
numFields := elemType.NumField()
// Collect headers from field names
headers := make([]string, numFields)
for i := 0; i < numFields; i++ {
headers[i] = elemType.Field(i).Name
}
// Collect row values as strings
rows := make([][]string, v.Len())
for i := 0; i < v.Len(); i++ {
row := make([]string, numFields)
for j := 0; j < numFields; j++ {
field := v.Index(i).Field(j)
if field.CanInterface() {
row[j] = fmt.Sprintf("%v", field.Interface())
} else {
row[j] = "(unexported)"
}
}
rows[i] = row
}
// Compute column widths
widths := make([]int, numFields)
for i, h := range headers {
widths[i] = len(h)
}
for _, row := range rows {
for i, cell := range row {
if len(cell) > widths[i] {
widths[i] = len(cell)
}
}
}
// Print header
for i, h := range headers {
fmt.Printf(" %-*s", widths[i], h)
if i < numFields-1 {
fmt.Print(" | ")
}
}
fmt.Println()
// Print separator
for i, w := range widths {
fmt.Print(" " + strings.Repeat("-", w))
if i < numFields-1 {
fmt.Print("-+-")
}
}
fmt.Println()
// Print rows
for _, row := range rows {
for i, cell := range row {
fmt.Printf(" %-*s", widths[i], cell)
if i < numFields-1 {
fmt.Print(" | ")
}
}
fmt.Println()
}
}
The function starts by validating that the input is a non-empty slice of structs. It reads the element type with v.Type().Elem() to discover the struct’s fields. Headers come from the field names, row values from formatting each field with fmt.Sprintf("%v", field.Interface()). The column width calculation passes over headers and all rows to find the widest string in each column, then uses %-*s format padding to align everything.
You can call it with any struct type:
1
2
3
4
5
6
7
8
9
10
11
12
13
type Book struct {
Title string
Author string
Pages int
}
books := []Book{
{"The Go Programming Language", "Donovan & Kernighan", 380},
{"Concurrency in Go", "Katherine Cox-Buday", 238},
{"Go in Action", "Kennedy, Ketelsen, St. Martin", 264},
}
PrintTable(books)
This prints a neatly aligned table with Title, Author, and Pages columns — without PrintTable knowing anything about the Book type at compile time. This is the same approach that encoding/json uses internally: it inspects struct fields via reflection to decide what to encode, reads struct tags to determine JSON key names, and handles exported and unexported fields differently. The difference is that encoding/json does this at scale with caching and optimization, but the underlying mechanism is the same reflect API shown here.