# [Go in Practice] Writing Modern Go with AI: Testing JetBrains go-modern-guidelines and Refactoring a 1,039-line main.go

> Source: <https://dev.to/gde/go-in-practice-writing-modern-go-with-ai-testing-jetbrains-go-modern-guidelines-and-refactoring-151o>
> Published: 2026-08-27 04:13:56+00:00

In the AI era, even I delegate most of my code optimization or writing tasks to AI. However, due to factors in model training data, too many writing styles are outdated. This results in code that cannot utilize features of the latest Go versions, which is quite a pity.

Fortunately, JetBrains released `go-modern-guidelines`

, a very useful plugin. It makes your AI Agent smarter and teaches it how to use the latest syntax to optimize your Golang code.

The positioning of this project is very straightforward: **Provide contemporary Go writing specifications for AI agents so they don't write outdated Go due to knowledge cutoffs.**

The problem has two layers. The first layer is easy to understand: training data has a cutoff. Anything added to the standard library after that cutoff won't be used because the model hasn't seen it. The project's own example is `errors.AsType[T]`

(Go 1.26); if the model hasn't seen it, it naturally won't write it.

The second layer is more subtle, which the project calls **frequency bias**: even if the model "knows" the new way, the old way appears overwhelmingly more often in the training data. In ten years of Go code on the internet, `interface{}`

appears far more than `any`

, and `sort.Slice`

far more than `slices.SortFunc`

. Models perform probabilistic prediction; the one that wins by majority vote is usually the old one.

I really saw this second point in this refactoring. The original project had this snippet:

```
// The oauth2 library can return an error containing "invalid_grant"
// when the refresh token is expired, revoked, or otherwise invalid.
if err != nil {
    errorStr := err.Error()
    // Basic substring check to avoid importing "strings"
    for i := 0; i <= len(errorStr)-13; i++ {
        if errorStr[i:i+13] == "invalid_grant" {
            return true
        }
    }
}
```

A hand-rolled string search, with a comment specifically explaining "to avoid importing strings". `strings`

is in the standard library; the cost of importing it is zero. What this code actually needed was just one line: `strings.Contains(err.Error(), "invalid_grant")`

.

The tool itself is a CLI with only two subcommands:

```
list [--go-version <version> | --file-path <path>]
    Returns a list of guidelines supported by this Go version, sorted from newest to oldest.

explain <id>...
    Returns detailed explanations and before/after examples for specific guidelines.
```

The key point of `list`

is that **it provides different answers based on the Go version**. You can pass a file path directly, and it will look up for `go.mod`

, `go.work`

, or fall back to the local Go toolchain:

``` bash
$ go-modern-guidelines list --file-path ~/Documents/linebot-file/main.go
```

My project's `go.mod`

specifies `go 1.24.0`

, so it returned 45 guidelines. Change the version number, and the count changes:

| Go Version | Guideline Count |
|---|---|
| 1.21 | 32 |
| 1.22 | 37 |
| 1.23 | 41 |
| 1.24 | 45 |
| 1.25 | 46 |
| 1.26 | 48 |
| 1.27 | 54 |

This design is intentional: **it only suggests syntax that your project version can actually use.** This is crucial for AI agents; otherwise, it might happily suggest `errors.AsType[T]`

, and your CI would fail because it's running on Go 1.24.

Looking at the differences between versions, it's essentially a condensed list of Go's recent features:

``` bash
$ diff <(list --go-version 1.21) <(list --go-version 1.22)
> range_over_int: Use for i := range n when iterating from 0 to n-1.
> loopvar_capture: Do not add redundant loop-variable copies before closures or
  taking addresses; Go 1.22 gives each iteration its own variables.
> cmp_or: Use cmp.Or to pick the first non-zero value from a fallback chain.
> reflect_type_for: Use reflect.TypeFor[T]() instead of reflect.TypeOf((*T)(nil)).Elem().
> http_servemux_patterns: Use method-aware ServeMux patterns and r.PathValue for
  path parameters.
```

`list`

provides a one-line summary; use `explain`

for detailed instructions when you're ready to work. Output looks like this:

``` bash
$ go-modern-guidelines explain cmp_or

cmp_or:
  Since: Go 1.22

  Summary:
    Use cmp.Or to pick the first non-zero value from a fallback chain.

  Details:
    cmp.Or returns the first non-zero value from its arguments. It is concise
    for simple fallback chains, but remember that all arguments are evaluated
    before the call.

  Examples:

  Before:
    name := os.Getenv("NAME")
    if name == "" {
      name = "default"
    }

  After:
    name := cmp.Or(os.Getenv("NAME"), "default")
```

Note the last sentence in the `Details`

section: "all arguments are evaluated before the call." This is the real trap of `cmp.Or`

—if your fallback source is an expensive function call, writing `cmp.Or(a(), b())`

will execute both. This kind of "you can use it, but know the cost" reminder is much more useful than just telling you to change the syntax.

This list/explain layering might look like just interface design, but it's actually for the AI agent's context window. 45 guidelines, each with a one-line summary, take about 1000 tokens; but if every guideline included full explanations and before/after examples, just stuffing this list would burn tens of thousands of tokens.

So the workflow is: first `list`

to scan everything, determine which guidelines are relevant to the current code, and then only call `explain`

for those. In this case, I actually only `explain`

ed six guidelines.

``` php
graph TD
    A[Prepare to modify Go code] --> B[list --file-path main.go]
    B --> C[Parse Go version from go.mod]
    C --> D[Return 45 guidelines available for that version<br/>one-line summary each]
    D --> E{Which ones are relevant to this code?}
    E -->|Pick candidates| F[explain cmp_or min_max ...]
    F --> G[Get detailed explanations and before/after]
    G --> H[Actually apply to code]
    E -->|None relevant| I[Write in original way]
```

There's one rule in the skill documentation written with particular emphasis: **Do not pipe the output of list to head, tail, or grep**, as you might miss important guidelines. I violated this rule on my first try, which I'll discuss in the "Pitfalls" section.

For Claude Code, it's two lines:

```
/plugin marketplace add JetBrains/go-modern-guidelines
/plugin install modern-go-guidelines@goland-claude-marketplace
```

Once installed, it triggers automatically for Go-related tasks, or you can call it manually: `/modern-go-guidelines:use-modern-go`

. Cursor, Junie, and Codex have their own installation methods; other agents can use `npx skills add JetBrains/go-modern-guidelines`

. The project is licensed under Apache 2.0.

On the first run, the wrapper script will automatically install the CLI to the local cache directory:

```
go-modern-guidelines: installing github.com/JetBrains/go-modern-guidelines@v0.1.1
  into /Users/xxx/.cache/go-modern-guidelines/v0.1.1
```

First, some background. The architecture of `linebot-file`

isn't complex:

``` php
graph LR
    A[LINE App] -->|Send file| B[LINE Platform]
    B -->|webhook| C[Cloud Run]
    C -->|Read token| D[(Firestore)]
    C -->|Upload/Query| E[Google Drive API]
```

Users authorize with `/connect_drive`

, tokens are stored in Firestore, and files sent to the chatroom are automatically uploaded to a folder structure like `LINE Bot Uploads/YYYY-MM/`

. Features were added incrementally, and everything was piled into `main.go`

, where the `main()`

function itself took up 564 lines.

This section isn't directly related to `go-modern-guidelines`

—that tool manages whether the "writing style is contemporary," not whether the "logic is correct." But these are the things that actually bite users, so I'll record them anyway.

This is the most interesting one. The original event handling looked like this:

```
switch e := event.(type) {
case webhook.MessageEvent:
    switch message := e.Message.(type) {
    case webhook.TextMessageContent:
        // ...
    case webhook.FileMessageContent:
        // ...
    case webhook.FollowEvent: // ← Note the indentation level here
        if s, ok := e.Source.(*webhook.UserSource); ok {
            bot.LinkRichMenuIdToUser(s.UserId, richMenuConnect)
        }
    }
}
```

`webhook.FollowEvent`

was written inside the **inner** switch. The inner switch evaluates `e.Message`

, which is of type `MessageContentInterface`

—a follow event can never be the content of a message.

Why did it compile? Go does check type switches; if a case type cannot possibly implement that interface, the compiler reports `impossible type switch case`

. The problem lies in the SDK's interface definition:

```
type MessageContentInterface interface {
    GetType() string
}
```

It only requires a `GetType() string`

. And `FollowEvent`

happens to have this method (all event types do), so in the type system, it "can" be a `MessageContentInterface`

. The compiler allows it, but it never matches at runtime.

Actual consequence: **When new users add the bot as a friend, the Rich Menu for guiding authorization was never bound.** This feature had probably been broken for a long time because it doesn't report an error; it just quietly does nothing.

```
userID := e.Source.(webhook.UserSource).UserId
```

Unchecked type assertions appeared six times. As long as the bot is pulled into a group and someone sends an image, this line panics.

By the way, several other places in the same file used `e.Source.(*webhook.GroupSource)`

(pointer). Checking the SDK's `UnmarshalSource`

, it returns a **value**, not a pointer, so those assertions with `, ok`

were always false—also dead code. Two different wrong ways in the same file, in opposite directions.

```
// When uploading: file is placed in LINE Bot Uploads/YYYY-MM/
monthFolderID, _ := findOrCreateFolder(srv, "2026-08", mainFolderID)
srv.Files.Create(&drive.File{Parents: []string{monthFolderID}})

// When querying: only look under LINE Bot Uploads
query := fmt.Sprintf("'%s' in parents and trashed=false", mainFolderID)
```

Files are stored in monthly subfolders, but the query only looks at the root folder. In the Google Drive data model, a folder is also a type of file, so this query does return things—it returns the `2026-08`

, `2026-07`

folders themselves.

```
query := fmt.Sprintf("... and name contains '%s'", searchQuery)
```

No escaping. If a user searches for `it's`

, that single quote breaks the query syntax; thinking further, extra query conditions could be injected. The fix is to properly write an escaping function, noting that the order cannot be reversed:

```
// Backslashes must be escaped first, otherwise the backslash added 
// to escape a quote will be escaped again in the second round of processing.
func escapeDriveQuery(s string) string {
    s = strings.ReplaceAll(s, `\`, `\\`)
    return strings.ReplaceAll(s, `'`, `\'`)
}
} else if (len(message.Text) > 13 && message.Text[:13] == "/search_files") ||
          (len(message.Text) > 2 && message.Text[:2] == "/q") {
    commandPrefixLen := 0
    if ... {
        commandPrefixLen = 14 // Length of "/search_files "
    } else if ... {
        commandPrefixLen = 3 // Length of "/q "
    }
    searchQuery = message.Text[commandPrefixLen:]
```

Manual string slicing, and it hardcoded "must be followed by a space." If a user types `/quit`

, the first two characters are `/q`

, so it becomes a search for `it`

.

Back to the topic. Out of those 45 guidelines from `list`

, these were the ones actually applied in this change:

`http_servemux_patterns`

: Also removed a piece of manual path checking
The original way was to have all requests go to the same handler and then determine the path manually:

```
http.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
    // LINE Platform must POST to the webhook URL
    if req.URL.Path != "/" {
        http.NotFound(w, req)
        return
    }
    // ...
})
```

After Go 1.22, `ServeMux`

patterns support methods and exact paths:

```
mux := http.NewServeMux()
// "/{$}" only matches the root path, not all paths beneath it
mux.HandleFunc("POST /{$}", webhookHandler)
mux.HandleFunc("GET /oauth/callback", oauthCallbackHandler)
// Cannot be called /healthz, see Pitfall 5 for the reason
mux.HandleFunc("GET /health", healthHandler)
```

The `{$}`

syntax is key: `"/"`

in `ServeMux`

is a subtree pattern that consumes all paths beneath it, which is why the original code needed that manual check. `"/{$}"`

only matches the root path itself, so the check is no longer needed. I also added method restrictions and a health check endpoint while I was at it.

The health check endpoint issue came up later, but that was discovered after deployment; I'll save that for Pitfall 5.

`cmp_or`

: Three fallback segments turned into three lines

```
// Before
port := os.Getenv("PORT")
if port == "" {
    port = "5000"
}

// After (also changed default to 8080 to align with Dockerfile EXPOSE and Cloud Run convention)
port := cmp.Or(os.Getenv("PORT"), "8080")
richMenuConnect = cmp.Or(os.Getenv("RICH_MENU_CONNECT"), defaultRichMenuConnect)
richMenuMain = cmp.Or(os.Getenv("RICH_MENU_MAIN"), defaultRichMenuMain)
```

This exactly fits the usage conditions reminded by `explain`

: all three parameters are `os.Getenv`

and constants, and evaluating them all has no side effects.

`strings_cut_prefix_suffix`

: Replacing manual string slicing
The command parsing from point five earlier, `message.Text[:13]`

, was replaced with a proper parsing function:

```
func parseCommand(text string) (name, arg string, ok bool) {
    text = strings.TrimSpace(text)
    if !strings.HasPrefix(text, "/") {
        return "", "", false
    }

    name, arg, _ = strings.Cut(text, " ")
    switch name {
    case cmdConnect, cmdReconnect, cmdDisconnect, cmdRecent, cmdSearch, cmdSearchShort:
        return name, strings.TrimSpace(arg), true
    }
    return "", "", false
}
```

Changing to `strings.Cut`

to first slice out the full command name and then using a switch for comparison structurally eliminated the `/quit`

bug—the sliced name is `/quit`

, which isn't in the allowed list, so it returns false directly.

`slices_sort_func`

+ `min`

: Fixing that fake sorting
The original search results looked like this after deduplication:

```
// Remove duplicates and sort by creation time (newest first)
uniqueFiles := make(map[string]*drive.File)
for _, file := range files {
    if _, exists := uniqueFiles[file.Id]; !exists {
        uniqueFiles[file.Id] = file
    }
}

result := make([]*drive.File, 0, len(uniqueFiles))
for _, file := range uniqueFiles {
    result = append(result, file)
}

if len(result) > 10 {
    result = result[:10]
}
```

The comment says "sort by creation time (newest first)", but in reality, there was no sorting action at all—**map iteration order is random**, and then it just truncated the first 10 items. So users got 10 random items, not the 10 newest ones.

```
// createdTime returned by Drive is an RFC 3339 UTC string; direct string comparison is the correct chronological order
func sortAndTrimFiles(files []*drive.File, limit int) []*drive.File {
    slices.SortStableFunc(files, func(a, b *drive.File) int {
        return cmp.Compare(b.CreatedTime, a.CreatedTime)
    })
    return files[:min(len(files), limit)]
}
```

The `min`

built-in function (Go 1.21) saves an `if`

here.

`any`

, `errors_is`

: Small details
Changing `map[string]interface{}`

to `map[string]any`

; no need to say more about such one-liners.

`crypto/rand.Text()`

: A suggestion that requires a version upgrade first
Original way to generate OAuth state:

```
func generateState() string {
    b := make([]byte, 16)
    rand.Read(b) // Error ignored
    return base64.URLEncoding.EncodeToString(b)
}
```

`crypto/rand.Text()`

, added in Go 1.24, returns a random string directly, won't fail, and the output is base32 (`A-Z`

, `2-7`

), which is naturally URL-safe—perfect for states and Firestore document IDs:

```
func generateState() string {
    return rand.Text()
}
```

But there's a prerequisite for this one, which I'll discuss below.

The skill documentation is clear:

Do not pipe the output through head, tail, grep, sed, or any other truncating/filtering command. Important guidelines may otherwise be missed.

The first time I called it, I typed:

``` bash
$ go-modern-guidelines list --file-path main.go 2>&1 | tail -60
```

Purely a reflex to avoid flooding the screen with long output. In hindsight, I realized this was dangerous on two levels: first, `list`

explicitly states it is **sorted from newest to oldest**, so `tail`

gets exactly the oldest batch; second, I only got away with it this time because the `go.mod`

specified 1.23, totaling 41 lines, which is less than 60, so `tail -60`

printed everything.

**Reason and Solution**: Pure luck. If the project had been Go 1.27 (54 guidelines), `tail -60`

still wouldn't have truncated; but if I had typed `head -20`

or `grep slices`

, I would have missed entire batches of items with absolutely no hint that I missed anything. This kind of "output truncated but looks normal" failure is the hardest to detect. Just read the full output honestly; it's only 45 lines.

`rand.Text()`

appeared in the suggestion list, but the project's `go.mod`

at the time was:

```
module github.com/kkdai/linebot-file

// +heroku goVersion go1.21
go 1.23.0

toolchain go1.24.3
```

The `go 1.23.0`

line determines the **language version**, which is different from the `toolchain`

. The tool answers based on the version it can parse, but to actually use `rand.Text()`

, you have to modify `go.mod`

.

It's not just about brainlessly changing one line; you have to ensure everything in the chain aligns: the `toolchain`

was already `go1.24.3`

, the Dockerfile used `golang:1.24-alpine`

, both were fine. However, the CI had an issue—`.github/workflows/go.yml`

hardcoded `go-version: '1.22'`

, which is older than what `go.mod`

required; it was currently only not breaking because of Go's automatic toolchain download mechanism.

**Reason and Solution**: Updated `go.mod`

to `go 1.24.0`

, cleared out that outdated `// +heroku goVersion go1.21`

line (this project has long been running on Cloud Run), and changed the CI to use `go.mod`

as the single source of truth:

```
- uses: actions/setup-go@v5
  with:
    # Use go.mod as the single source of truth to avoid CI and project version inconsistency
    go-version-file: go.mod
```

This was the most interesting discovery this time. After upgrading `go.mod`

, I ran `list`

again before writing tests and found four new items at the top of the list:

```
testing_t_context: Use t.Context() when a test function needs a context tied to
                   the test lifetime.
json_omitzero: Use omitzero on JSON-tagged bool, numeric, struct, and time
                   fields whose zero value should be omitted...
testing_b_loop: Use b.Loop() for the main loop in benchmark functions.
strings_split_seq: Use strings or bytes SplitSeq and FieldsSeq helpers...
```

These four are exactly what was added in Go 1.24. And `testing_t_context`

directly changed the test I was currently writing:

```
// Before
srv, err := drive.NewService(context.Background(),
    option.WithEndpoint(server.URL), option.WithoutAuthentication())

// After — context bound to test lifetime, automatically canceled when test ends
srv, err := drive.NewService(t.Context(),
    option.WithEndpoint(server.URL), option.WithoutAuthentication())
```

**Reason and Solution**: The output of this tool **changes according to the project state**; it's not a static document. Upgrade the version or switch projects, and the answers change. So the correct usage isn't to check once before starting and be done, but to **rerun it when the nature of the changes shifts**—in my case, I reran it at the junction of "finished main program, starting tests," and happened to catch `testing_t_context`

. If I had only checked at the very beginning, I would have missed it.

This is the reverse: where `go-modern-guidelines`

doesn't and shouldn't have an opinion.

Originally, file uploads were done synchronously in the webhook handler: downloading a video from LINE and then uploading to Drive could take dozens of seconds. LINE expects a response within a certain time; if it times out, it retries, and retries would cause **duplicate uploads of the same file**.

The standard advice for this is almost reflexive: return 200 first, and throw the rest into a goroutine. I thought the same at first, but halfway through writing, I remembered something—**this service runs on Cloud Run, which by default only allocates CPU during request processing.** Once the response is sent, that goroutine will be throttled by the CPU, becoming a black hole that looks like it's working but actually has no idea when it will finish. This is worse than synchronous processing; at least synchronous processing fails honestly.

**Reason and Solution**: Changed to use the webhook's event ID for deduplication so that retries don't cause duplicate uploads, while keeping synchronous processing:

```
// handledEvents remembers recently processed webhook event IDs. LINE will resend
// requests it considers failed; without this protection, resending would upload the same file again.
type handledEvents struct {
    mu sync.Mutex
    seen map[string]time.Time
}

func (h *handledEvents) markHandled(id string) bool {
    if id == "" {
        return true // Without an ID, there's no way to deduplicate, so treat as a new event
    }
    // ... clear expired ones, then check for duplicates
}
```

Added a fallback of "if the reply token expires, use push message" so that the user is still notified after a large file finishes uploading.

This is a compromise; the real solution is to use Cloud Tasks or Pub/Sub. I've added it to the project roadmap, including the reason "can't just use goroutines"—otherwise, the next person taking over (likely me in three months) will probably hit the same pitfall again.

After the PR was merged, I checked Cloud Build with `gcloud`

; the status was SUCCESS, the new revision was Ready, and all traffic was switched over. It looked like a job well done.

Poked the endpoints:

```
GET / 405 ← method-aware ServeMux in effect
POST / No signature 400 ← signature verification in effect
GET /nope 404 ← {$} exact match in effect
GET /healthz 404 ← ?
```

The first three were correct, but the health check returned 404.

At first, I thought I wrote the route wrong, but I only realized something was off after printing the response content—it was a Google-branded HTML error page (`Error 404 (Not Found)!!1`

, with the Google robot image), not Go's `404 page not found`

plain text. This meant the request never even reached my program.

Checking the Cloud Run request logs confirmed this:

```
15:44:54 GET 400 /oauth/callback
15:44:36 GET 404 /nope
15:44:36 POST 400 /
15:44:36 GET 405 /
```

I sent five requests, but there were only four in the log. The two `/healthz`

requests didn't even have a record.

Scanning various common health check paths narrowed it down significantly:

```
/healthz 404 GFE(Google) ← Intercepted
/healthz/ 404 app(Go) ← Only one slash difference
/health 404 app(Go)
/readyz 404 app(Go)
/livez 404 app(Go)
/_ah/health 404 app(Go)
/status 404 app(Go)
/ping 404 app(Go)
/healthcheck 404 app(Go)
```

Only the exact path `/healthz`

was intercepted by Google Frontend; even adding a slash allowed it to reach the app normally. I looked it up and found this is a known behavior of Cloud Run, which [Streamlit](https://github.com/streamlit/streamlit/issues/3028) and [n8n](https://github.com/n8n-io/n8n/issues/26163) have also encountered.

**Reason and Solution**: Changed the endpoint to `/health`

, a one-line fix. The annoying part is that this pitfall doesn't make a sound—`go vet`

doesn't speak, tests don't speak, CI is all green, build succeeds, Cloud Run shows Ready, and even request logs leave no trace. The only way to find it is to actually poke the endpoint and notice that the returned 404 looks different from the one your program returns.

So when fixing it, I left a comment in the code and wrote a section in the README:

```
// Not "/healthz": Cloud Run's frontend reserves that exact path and
// answers it with its own 404, so the request never reaches us.
mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
```

Without this line, the next person who sees `/health`

and thinks "this isn't the convention, it should be healthz" (very likely myself) will change it back.

While fixing `/healthz`

, I scanned the code again and found something even more embarrassing.

One change I was very satisfied with earlier was "using context throughout the process, with timeouts for all external calls." Firestore had it, Drive had it. Then I grepped all external calls:

```
webhook.go:312 blob.GetMessageContent(messageID)
line.go:73 bot.ReplyMessage(...)
line.go:94 bot.PushMessage(...)
line.go:118 bot.LinkRichMenuIdToUser(...)
```

Four LINE calls, none of which took a context. I had to dig into the SDK to find out why:

```
c := &MessagingApiAPI{
    channelToken: channelToken,
    httpClient: http.DefaultClient, // ← Timeout is zero, meaning no timeout
}
```

And the method signatures generated by the SDK don't accept `context`

, so the timeout I wrapped in the outer handler had absolutely no effect on these four calls. If the LINE side hangs, the goroutine just hangs indefinitely.

**Reason and Solution**: The problem wasn't that I didn't know to add a timeout, but that the memory of "I've added context" overrode the fact of "whether this SDK actually accepts context." When modifying Drive and Firestore, I added `.Context(ctx)`

all the way down very smoothly—so smoothly that I didn't stop to think about which other external calls didn't look like that.

The SDK provides injection points:

```
bot, err = messaging_api.NewMessagingApiAPI(accessToken,
    messaging_api.WithHTTPClient(&http.Client{Timeout: lineAPITimeout})) // 10 seconds

blob, err = messaging_api.NewMessagingApiBlobAPI(accessToken,
    messaging_api.WithBlobHTTPClient(&http.Client{Timeout: lineBlobTimeout})) // 5 minutes
```

I gave the blob side 5 minutes because it needs to download videos sent by users.

Along with a more hidden trap. The SDK provides something that looks exactly like what I wanted:

```
func (call *MessagingApiAPI) WithContext(ctx context.Context) *MessagingApiAPI {
    call.ctx = ctx
    return call
}
```

It directly overwrites the field of a shared structure and then returns the same pointer. My `bot`

is a package-level shared variable; if multiple requests come in simultaneously and each calls `WithContext`

, it's a standard data race. The name sounds like a functional option, but the behavior is mutation. I also left a comment for this line in the code to prevent someone from changing it later thinking it's more precise than `WithHTTPClient`

.

There was another one found in the same round. The `uploadParents`

function is responsible for listing all `YYYY-MM`

month folders, originally written like this:

```
r, err := srv.Files.List().Q(query).Fields("files(id)").Context(ctx).Do()
```

No `PageSize`

set. The Drive API defaults to 100 items per page; anything beyond that requires asking again with a `nextPageToken`

. One folder is generated per month, so after 100 months—about 8 years and 4 months—the oldest folders would disappear from the scope of searches and `/recent_files`

. There would be no error, no warning, just fewer results.

**Reason and Solution**: Changed to use `Pages()`

to iterate through all pages. What I really want to talk about is the next step. I didn't quite trust the pagination logic I just wrote, so I wrote a mock server that returns a `nextPageToken`

and then temporarily changed the implementation back to only fetch the first page to see if the test would fail:

```
--- FAIL: TestUploadParentsPagesThroughAllSubfolders
    uploadParents() = [root_id month_1 month_2], want [root_id month_1 month_2 month_3]
```

Confirmed it would fail before restoring the implementation. This step took less than two minutes, but without it, I would only have a "ran and passed" test without knowing if it was actually testing anything. Bugs like silent truncation don't reveal themselves; if the test is also a green illusion, you have nothing.

First, the most direct numbers. Originally, `main.go`

was 1039 lines and `main()`

was 564 lines. After splitting into six files:

| File | Lines | Responsibility |
|---|---|---|
`main.go` |
94 | Startup, environment variable checks, routing |
`config.go` |
74 | Constants and shared state |
`webhook.go` |
344 | Event dispatching, command parsing, command handling |
`line.go` |
164 | LINE message assembly |
`drive.go` |
183 | Drive query/upload |
`auth.go` |
264 | OAuth, token, revocation |

`main()`

went from 564 lines to 62 lines. Interestingly, **the total lines of main code barely changed** (1039 → 1123); what significantly increased was the tests: from 88 lines to 468 lines, and the number of tests from 1 to 16, all passing under `-race`

.

In terms of performance, the search function was originally "find the root folder, then check each monthly subfolder one by one," which is `1 + N`

Drive API calls; after changing to concatenate all parents with `or`

into a single query, it's fixed at 2 calls.

**The value of go-modern-guidelines isn't that it taught me syntax I'd never seen.** I generally knew about

`cmp.Or`

, `min`

, and `slices.SortFunc`

; the problem is that I don't actively think of them while coding—especially when modifying an existing file, where the surrounding old syntax creates a kind of gravity, making it natural to continue writing in the same style. A sentence in the skill documentation hits the mark:If a guideline applies, follow it even when nearby code or repository convention uses an older pattern.

This sentence is fighting the frequency bias mentioned earlier, and it applies to humans too.

**Its boundaries are also very clear.** Those five bugs that actually bite users—the wrong-level switch, the panicking type assertion, the query returning folders, the unescaped query, the misjudged `/quit`

—none of them were caught by `go-modern-guidelines`

; that's not its defensive scope. It manages whether "this Go code is contemporary enough," not whether "this logic is correct." Treat it as a supplement to a linter, not a replacement for code review.

**The first half of this article was written before deployment.** The `/healthz`

pitfall was discovered after the article was finished and the PR merged, when I checked the build status on `gcloud`

. The situation was: all 45 guidelines checked, everything applicable applied, 17 tests passed under `-race`

, all three CI checks green, Cloud Build SUCCESS, and Cloud Run showing Ready with 100% traffic switched. In this entire row of green lights, not a single one told you an endpoint was dead.

I wrote in [the previous post about handling Cloudflare](https://dev.to/help-handle-cloudflare/) that "a successful build cannot be taken as verification," and I thought I remembered it, but I still paid tuition in the same place this time, just on a different layer—last time the build succeeded but the container crashed on startup; this time the container ran fine but was eaten by the outer infrastructure layer. Tools manage syntax, tests manage logic, CI manages whether these two have regressed, but none of them manage "what happens when this thing is deployed to that specific environment." That part you have to poke yourself.

**Keep in mind that the output changes with the project state.** The phenomenon in Pitfall 3 where "four more suggestions appeared after upgrading go.mod" was the most practical realization this time. It's not a static document to be checked once, but a query interface that answers based on the project's current state. When the nature of the changes shifts (from main program to tests, language version upgrade, project switch), it's worth running again.

Finally, this change is in [PR #4](https://github.com/kkdai/linebot-file/pull/4), the two fixes added after deployment are in [#5](https://github.com/kkdai/linebot-file/pull/5) and [#6](https://github.com/kkdai/linebot-file/pull/6), and the code is at [kkdai/linebot-file](https://github.com/kkdai/linebot-file). The source code for `go-modern-guidelines`

is at [JetBrains/go-modern-guidelines](https://github.com/JetBrains/go-modern-guidelines), licensed under Apache 2.0.
