{"slug": "go-in-practice-writing-modern-go-with-ai-testing-jetbrains-go-modern-guidelines", "title": "[Go in Practice] Writing Modern Go with AI: Testing JetBrains go-modern-guidelines and Refactoring a 1,039-line main.go", "summary": "JetBrains has released go-modern-guidelines, a plugin that provides contemporary Go writing specifications for AI agents to prevent them from writing outdated code due to training data cutoffs and frequency bias. The tool offers a CLI with list and explain subcommands, tailoring suggestions based on the Go version specified in a project's go.mod. In a test refactoring of a 1,039-line main.go, the plugin helped replace a hand-rolled string search with a standard library call, demonstrating its practical utility.", "body_md": "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.\n\nFortunately, JetBrains released `go-modern-guidelines`\n\n, a very useful plugin. It makes your AI Agent smarter and teaches it how to use the latest syntax to optimize your Golang code.\n\nThe 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.**\n\nThe 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]`\n\n(Go 1.26); if the model hasn't seen it, it naturally won't write it.\n\nThe 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{}`\n\nappears far more than `any`\n\n, and `sort.Slice`\n\nfar more than `slices.SortFunc`\n\n. Models perform probabilistic prediction; the one that wins by majority vote is usually the old one.\n\nI really saw this second point in this refactoring. The original project had this snippet:\n\n```\n// The oauth2 library can return an error containing \"invalid_grant\"\n// when the refresh token is expired, revoked, or otherwise invalid.\nif err != nil {\n    errorStr := err.Error()\n    // Basic substring check to avoid importing \"strings\"\n    for i := 0; i <= len(errorStr)-13; i++ {\n        if errorStr[i:i+13] == \"invalid_grant\" {\n            return true\n        }\n    }\n}\n```\n\nA hand-rolled string search, with a comment specifically explaining \"to avoid importing strings\". `strings`\n\nis 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\")`\n\n.\n\nThe tool itself is a CLI with only two subcommands:\n\n```\nlist [--go-version <version> | --file-path <path>]\n    Returns a list of guidelines supported by this Go version, sorted from newest to oldest.\n\nexplain <id>...\n    Returns detailed explanations and before/after examples for specific guidelines.\n```\n\nThe key point of `list`\n\nis 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`\n\n, `go.work`\n\n, or fall back to the local Go toolchain:\n\n``` bash\n$ go-modern-guidelines list --file-path ~/Documents/linebot-file/main.go\n```\n\nMy project's `go.mod`\n\nspecifies `go 1.24.0`\n\n, so it returned 45 guidelines. Change the version number, and the count changes:\n\n| Go Version | Guideline Count |\n|---|---|\n| 1.21 | 32 |\n| 1.22 | 37 |\n| 1.23 | 41 |\n| 1.24 | 45 |\n| 1.25 | 46 |\n| 1.26 | 48 |\n| 1.27 | 54 |\n\nThis 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]`\n\n, and your CI would fail because it's running on Go 1.24.\n\nLooking at the differences between versions, it's essentially a condensed list of Go's recent features:\n\n``` bash\n$ diff <(list --go-version 1.21) <(list --go-version 1.22)\n> range_over_int: Use for i := range n when iterating from 0 to n-1.\n> loopvar_capture: Do not add redundant loop-variable copies before closures or\n  taking addresses; Go 1.22 gives each iteration its own variables.\n> cmp_or: Use cmp.Or to pick the first non-zero value from a fallback chain.\n> reflect_type_for: Use reflect.TypeFor[T]() instead of reflect.TypeOf((*T)(nil)).Elem().\n> http_servemux_patterns: Use method-aware ServeMux patterns and r.PathValue for\n  path parameters.\n```\n\n`list`\n\nprovides a one-line summary; use `explain`\n\nfor detailed instructions when you're ready to work. Output looks like this:\n\n``` bash\n$ go-modern-guidelines explain cmp_or\n\ncmp_or:\n  Since: Go 1.22\n\n  Summary:\n    Use cmp.Or to pick the first non-zero value from a fallback chain.\n\n  Details:\n    cmp.Or returns the first non-zero value from its arguments. It is concise\n    for simple fallback chains, but remember that all arguments are evaluated\n    before the call.\n\n  Examples:\n\n  Before:\n    name := os.Getenv(\"NAME\")\n    if name == \"\" {\n      name = \"default\"\n    }\n\n  After:\n    name := cmp.Or(os.Getenv(\"NAME\"), \"default\")\n```\n\nNote the last sentence in the `Details`\n\nsection: \"all arguments are evaluated before the call.\" This is the real trap of `cmp.Or`\n\n—if your fallback source is an expensive function call, writing `cmp.Or(a(), b())`\n\nwill 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.\n\nThis 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.\n\nSo the workflow is: first `list`\n\nto scan everything, determine which guidelines are relevant to the current code, and then only call `explain`\n\nfor those. In this case, I actually only `explain`\n\ned six guidelines.\n\n``` php\ngraph TD\n    A[Prepare to modify Go code] --> B[list --file-path main.go]\n    B --> C[Parse Go version from go.mod]\n    C --> D[Return 45 guidelines available for that version<br/>one-line summary each]\n    D --> E{Which ones are relevant to this code?}\n    E -->|Pick candidates| F[explain cmp_or min_max ...]\n    F --> G[Get detailed explanations and before/after]\n    G --> H[Actually apply to code]\n    E -->|None relevant| I[Write in original way]\n```\n\nThere'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.\n\nFor Claude Code, it's two lines:\n\n```\n/plugin marketplace add JetBrains/go-modern-guidelines\n/plugin install modern-go-guidelines@goland-claude-marketplace\n```\n\nOnce installed, it triggers automatically for Go-related tasks, or you can call it manually: `/modern-go-guidelines:use-modern-go`\n\n. Cursor, Junie, and Codex have their own installation methods; other agents can use `npx skills add JetBrains/go-modern-guidelines`\n\n. The project is licensed under Apache 2.0.\n\nOn the first run, the wrapper script will automatically install the CLI to the local cache directory:\n\n```\ngo-modern-guidelines: installing github.com/JetBrains/go-modern-guidelines@v0.1.1\n  into /Users/xxx/.cache/go-modern-guidelines/v0.1.1\n```\n\nFirst, some background. The architecture of `linebot-file`\n\nisn't complex:\n\n``` php\ngraph LR\n    A[LINE App] -->|Send file| B[LINE Platform]\n    B -->|webhook| C[Cloud Run]\n    C -->|Read token| D[(Firestore)]\n    C -->|Upload/Query| E[Google Drive API]\n```\n\nUsers authorize with `/connect_drive`\n\n, tokens are stored in Firestore, and files sent to the chatroom are automatically uploaded to a folder structure like `LINE Bot Uploads/YYYY-MM/`\n\n. Features were added incrementally, and everything was piled into `main.go`\n\n, where the `main()`\n\nfunction itself took up 564 lines.\n\nThis section isn't directly related to `go-modern-guidelines`\n\n—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.\n\nThis is the most interesting one. The original event handling looked like this:\n\n```\nswitch e := event.(type) {\ncase webhook.MessageEvent:\n    switch message := e.Message.(type) {\n    case webhook.TextMessageContent:\n        // ...\n    case webhook.FileMessageContent:\n        // ...\n    case webhook.FollowEvent: // ← Note the indentation level here\n        if s, ok := e.Source.(*webhook.UserSource); ok {\n            bot.LinkRichMenuIdToUser(s.UserId, richMenuConnect)\n        }\n    }\n}\n```\n\n`webhook.FollowEvent`\n\nwas written inside the **inner** switch. The inner switch evaluates `e.Message`\n\n, which is of type `MessageContentInterface`\n\n—a follow event can never be the content of a message.\n\nWhy did it compile? Go does check type switches; if a case type cannot possibly implement that interface, the compiler reports `impossible type switch case`\n\n. The problem lies in the SDK's interface definition:\n\n```\ntype MessageContentInterface interface {\n    GetType() string\n}\n```\n\nIt only requires a `GetType() string`\n\n. And `FollowEvent`\n\nhappens to have this method (all event types do), so in the type system, it \"can\" be a `MessageContentInterface`\n\n. The compiler allows it, but it never matches at runtime.\n\nActual 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.\n\n```\nuserID := e.Source.(webhook.UserSource).UserId\n```\n\nUnchecked type assertions appeared six times. As long as the bot is pulled into a group and someone sends an image, this line panics.\n\nBy the way, several other places in the same file used `e.Source.(*webhook.GroupSource)`\n\n(pointer). Checking the SDK's `UnmarshalSource`\n\n, it returns a **value**, not a pointer, so those assertions with `, ok`\n\nwere always false—also dead code. Two different wrong ways in the same file, in opposite directions.\n\n```\n// When uploading: file is placed in LINE Bot Uploads/YYYY-MM/\nmonthFolderID, _ := findOrCreateFolder(srv, \"2026-08\", mainFolderID)\nsrv.Files.Create(&drive.File{Parents: []string{monthFolderID}})\n\n// When querying: only look under LINE Bot Uploads\nquery := fmt.Sprintf(\"'%s' in parents and trashed=false\", mainFolderID)\n```\n\nFiles 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`\n\n, `2026-07`\n\nfolders themselves.\n\n```\nquery := fmt.Sprintf(\"... and name contains '%s'\", searchQuery)\n```\n\nNo escaping. If a user searches for `it's`\n\n, 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:\n\n```\n// Backslashes must be escaped first, otherwise the backslash added \n// to escape a quote will be escaped again in the second round of processing.\nfunc escapeDriveQuery(s string) string {\n    s = strings.ReplaceAll(s, `\\`, `\\\\`)\n    return strings.ReplaceAll(s, `'`, `\\'`)\n}\n} else if (len(message.Text) > 13 && message.Text[:13] == \"/search_files\") ||\n          (len(message.Text) > 2 && message.Text[:2] == \"/q\") {\n    commandPrefixLen := 0\n    if ... {\n        commandPrefixLen = 14 // Length of \"/search_files \"\n    } else if ... {\n        commandPrefixLen = 3 // Length of \"/q \"\n    }\n    searchQuery = message.Text[commandPrefixLen:]\n```\n\nManual string slicing, and it hardcoded \"must be followed by a space.\" If a user types `/quit`\n\n, the first two characters are `/q`\n\n, so it becomes a search for `it`\n\n.\n\nBack to the topic. Out of those 45 guidelines from `list`\n\n, these were the ones actually applied in this change:\n\n`http_servemux_patterns`\n\n: Also removed a piece of manual path checking\nThe original way was to have all requests go to the same handler and then determine the path manually:\n\n```\nhttp.HandleFunc(\"/\", func(w http.ResponseWriter, req *http.Request) {\n    // LINE Platform must POST to the webhook URL\n    if req.URL.Path != \"/\" {\n        http.NotFound(w, req)\n        return\n    }\n    // ...\n})\n```\n\nAfter Go 1.22, `ServeMux`\n\npatterns support methods and exact paths:\n\n```\nmux := http.NewServeMux()\n// \"/{$}\" only matches the root path, not all paths beneath it\nmux.HandleFunc(\"POST /{$}\", webhookHandler)\nmux.HandleFunc(\"GET /oauth/callback\", oauthCallbackHandler)\n// Cannot be called /healthz, see Pitfall 5 for the reason\nmux.HandleFunc(\"GET /health\", healthHandler)\n```\n\nThe `{$}`\n\nsyntax is key: `\"/\"`\n\nin `ServeMux`\n\nis a subtree pattern that consumes all paths beneath it, which is why the original code needed that manual check. `\"/{$}\"`\n\nonly 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.\n\nThe health check endpoint issue came up later, but that was discovered after deployment; I'll save that for Pitfall 5.\n\n`cmp_or`\n\n: Three fallback segments turned into three lines\n\n```\n// Before\nport := os.Getenv(\"PORT\")\nif port == \"\" {\n    port = \"5000\"\n}\n\n// After (also changed default to 8080 to align with Dockerfile EXPOSE and Cloud Run convention)\nport := cmp.Or(os.Getenv(\"PORT\"), \"8080\")\nrichMenuConnect = cmp.Or(os.Getenv(\"RICH_MENU_CONNECT\"), defaultRichMenuConnect)\nrichMenuMain = cmp.Or(os.Getenv(\"RICH_MENU_MAIN\"), defaultRichMenuMain)\n```\n\nThis exactly fits the usage conditions reminded by `explain`\n\n: all three parameters are `os.Getenv`\n\nand constants, and evaluating them all has no side effects.\n\n`strings_cut_prefix_suffix`\n\n: Replacing manual string slicing\nThe command parsing from point five earlier, `message.Text[:13]`\n\n, was replaced with a proper parsing function:\n\n```\nfunc parseCommand(text string) (name, arg string, ok bool) {\n    text = strings.TrimSpace(text)\n    if !strings.HasPrefix(text, \"/\") {\n        return \"\", \"\", false\n    }\n\n    name, arg, _ = strings.Cut(text, \" \")\n    switch name {\n    case cmdConnect, cmdReconnect, cmdDisconnect, cmdRecent, cmdSearch, cmdSearchShort:\n        return name, strings.TrimSpace(arg), true\n    }\n    return \"\", \"\", false\n}\n```\n\nChanging to `strings.Cut`\n\nto first slice out the full command name and then using a switch for comparison structurally eliminated the `/quit`\n\nbug—the sliced name is `/quit`\n\n, which isn't in the allowed list, so it returns false directly.\n\n`slices_sort_func`\n\n+ `min`\n\n: Fixing that fake sorting\nThe original search results looked like this after deduplication:\n\n```\n// Remove duplicates and sort by creation time (newest first)\nuniqueFiles := make(map[string]*drive.File)\nfor _, file := range files {\n    if _, exists := uniqueFiles[file.Id]; !exists {\n        uniqueFiles[file.Id] = file\n    }\n}\n\nresult := make([]*drive.File, 0, len(uniqueFiles))\nfor _, file := range uniqueFiles {\n    result = append(result, file)\n}\n\nif len(result) > 10 {\n    result = result[:10]\n}\n```\n\nThe 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.\n\n```\n// createdTime returned by Drive is an RFC 3339 UTC string; direct string comparison is the correct chronological order\nfunc sortAndTrimFiles(files []*drive.File, limit int) []*drive.File {\n    slices.SortStableFunc(files, func(a, b *drive.File) int {\n        return cmp.Compare(b.CreatedTime, a.CreatedTime)\n    })\n    return files[:min(len(files), limit)]\n}\n```\n\nThe `min`\n\nbuilt-in function (Go 1.21) saves an `if`\n\nhere.\n\n`any`\n\n, `errors_is`\n\n: Small details\nChanging `map[string]interface{}`\n\nto `map[string]any`\n\n; no need to say more about such one-liners.\n\n`crypto/rand.Text()`\n\n: A suggestion that requires a version upgrade first\nOriginal way to generate OAuth state:\n\n```\nfunc generateState() string {\n    b := make([]byte, 16)\n    rand.Read(b) // Error ignored\n    return base64.URLEncoding.EncodeToString(b)\n}\n```\n\n`crypto/rand.Text()`\n\n, added in Go 1.24, returns a random string directly, won't fail, and the output is base32 (`A-Z`\n\n, `2-7`\n\n), which is naturally URL-safe—perfect for states and Firestore document IDs:\n\n```\nfunc generateState() string {\n    return rand.Text()\n}\n```\n\nBut there's a prerequisite for this one, which I'll discuss below.\n\nThe skill documentation is clear:\n\nDo not pipe the output through head, tail, grep, sed, or any other truncating/filtering command. Important guidelines may otherwise be missed.\n\nThe first time I called it, I typed:\n\n``` bash\n$ go-modern-guidelines list --file-path main.go 2>&1 | tail -60\n```\n\nPurely a reflex to avoid flooding the screen with long output. In hindsight, I realized this was dangerous on two levels: first, `list`\n\nexplicitly states it is **sorted from newest to oldest**, so `tail`\n\ngets exactly the oldest batch; second, I only got away with it this time because the `go.mod`\n\nspecified 1.23, totaling 41 lines, which is less than 60, so `tail -60`\n\nprinted everything.\n\n**Reason and Solution**: Pure luck. If the project had been Go 1.27 (54 guidelines), `tail -60`\n\nstill wouldn't have truncated; but if I had typed `head -20`\n\nor `grep slices`\n\n, 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.\n\n`rand.Text()`\n\nappeared in the suggestion list, but the project's `go.mod`\n\nat the time was:\n\n```\nmodule github.com/kkdai/linebot-file\n\n// +heroku goVersion go1.21\ngo 1.23.0\n\ntoolchain go1.24.3\n```\n\nThe `go 1.23.0`\n\nline determines the **language version**, which is different from the `toolchain`\n\n. The tool answers based on the version it can parse, but to actually use `rand.Text()`\n\n, you have to modify `go.mod`\n\n.\n\nIt's not just about brainlessly changing one line; you have to ensure everything in the chain aligns: the `toolchain`\n\nwas already `go1.24.3`\n\n, the Dockerfile used `golang:1.24-alpine`\n\n, both were fine. However, the CI had an issue—`.github/workflows/go.yml`\n\nhardcoded `go-version: '1.22'`\n\n, which is older than what `go.mod`\n\nrequired; it was currently only not breaking because of Go's automatic toolchain download mechanism.\n\n**Reason and Solution**: Updated `go.mod`\n\nto `go 1.24.0`\n\n, cleared out that outdated `// +heroku goVersion go1.21`\n\nline (this project has long been running on Cloud Run), and changed the CI to use `go.mod`\n\nas the single source of truth:\n\n```\n- uses: actions/setup-go@v5\n  with:\n    # Use go.mod as the single source of truth to avoid CI and project version inconsistency\n    go-version-file: go.mod\n```\n\nThis was the most interesting discovery this time. After upgrading `go.mod`\n\n, I ran `list`\n\nagain before writing tests and found four new items at the top of the list:\n\n```\ntesting_t_context: Use t.Context() when a test function needs a context tied to\n                   the test lifetime.\njson_omitzero: Use omitzero on JSON-tagged bool, numeric, struct, and time\n                   fields whose zero value should be omitted...\ntesting_b_loop: Use b.Loop() for the main loop in benchmark functions.\nstrings_split_seq: Use strings or bytes SplitSeq and FieldsSeq helpers...\n```\n\nThese four are exactly what was added in Go 1.24. And `testing_t_context`\n\ndirectly changed the test I was currently writing:\n\n```\n// Before\nsrv, err := drive.NewService(context.Background(),\n    option.WithEndpoint(server.URL), option.WithoutAuthentication())\n\n// After — context bound to test lifetime, automatically canceled when test ends\nsrv, err := drive.NewService(t.Context(),\n    option.WithEndpoint(server.URL), option.WithoutAuthentication())\n```\n\n**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`\n\n. If I had only checked at the very beginning, I would have missed it.\n\nThis is the reverse: where `go-modern-guidelines`\n\ndoesn't and shouldn't have an opinion.\n\nOriginally, 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**.\n\nThe 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.\n\n**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:\n\n```\n// handledEvents remembers recently processed webhook event IDs. LINE will resend\n// requests it considers failed; without this protection, resending would upload the same file again.\ntype handledEvents struct {\n    mu sync.Mutex\n    seen map[string]time.Time\n}\n\nfunc (h *handledEvents) markHandled(id string) bool {\n    if id == \"\" {\n        return true // Without an ID, there's no way to deduplicate, so treat as a new event\n    }\n    // ... clear expired ones, then check for duplicates\n}\n```\n\nAdded a fallback of \"if the reply token expires, use push message\" so that the user is still notified after a large file finishes uploading.\n\nThis 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.\n\nAfter the PR was merged, I checked Cloud Build with `gcloud`\n\n; the status was SUCCESS, the new revision was Ready, and all traffic was switched over. It looked like a job well done.\n\nPoked the endpoints:\n\n```\nGET / 405 ← method-aware ServeMux in effect\nPOST / No signature 400 ← signature verification in effect\nGET /nope 404 ← {$} exact match in effect\nGET /healthz 404 ← ?\n```\n\nThe first three were correct, but the health check returned 404.\n\nAt 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`\n\n, with the Google robot image), not Go's `404 page not found`\n\nplain text. This meant the request never even reached my program.\n\nChecking the Cloud Run request logs confirmed this:\n\n```\n15:44:54 GET 400 /oauth/callback\n15:44:36 GET 404 /nope\n15:44:36 POST 400 /\n15:44:36 GET 405 /\n```\n\nI sent five requests, but there were only four in the log. The two `/healthz`\n\nrequests didn't even have a record.\n\nScanning various common health check paths narrowed it down significantly:\n\n```\n/healthz 404 GFE(Google) ← Intercepted\n/healthz/ 404 app(Go) ← Only one slash difference\n/health 404 app(Go)\n/readyz 404 app(Go)\n/livez 404 app(Go)\n/_ah/health 404 app(Go)\n/status 404 app(Go)\n/ping 404 app(Go)\n/healthcheck 404 app(Go)\n```\n\nOnly the exact path `/healthz`\n\nwas 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.\n\n**Reason and Solution**: Changed the endpoint to `/health`\n\n, a one-line fix. The annoying part is that this pitfall doesn't make a sound—`go vet`\n\ndoesn'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.\n\nSo when fixing it, I left a comment in the code and wrote a section in the README:\n\n```\n// Not \"/healthz\": Cloud Run's frontend reserves that exact path and\n// answers it with its own 404, so the request never reaches us.\nmux.HandleFunc(\"GET /health\", func(w http.ResponseWriter, _ *http.Request) {\n```\n\nWithout this line, the next person who sees `/health`\n\nand thinks \"this isn't the convention, it should be healthz\" (very likely myself) will change it back.\n\nWhile fixing `/healthz`\n\n, I scanned the code again and found something even more embarrassing.\n\nOne 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:\n\n```\nwebhook.go:312 blob.GetMessageContent(messageID)\nline.go:73 bot.ReplyMessage(...)\nline.go:94 bot.PushMessage(...)\nline.go:118 bot.LinkRichMenuIdToUser(...)\n```\n\nFour LINE calls, none of which took a context. I had to dig into the SDK to find out why:\n\n```\nc := &MessagingApiAPI{\n    channelToken: channelToken,\n    httpClient: http.DefaultClient, // ← Timeout is zero, meaning no timeout\n}\n```\n\nAnd the method signatures generated by the SDK don't accept `context`\n\n, 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.\n\n**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)`\n\nall the way down very smoothly—so smoothly that I didn't stop to think about which other external calls didn't look like that.\n\nThe SDK provides injection points:\n\n```\nbot, err = messaging_api.NewMessagingApiAPI(accessToken,\n    messaging_api.WithHTTPClient(&http.Client{Timeout: lineAPITimeout})) // 10 seconds\n\nblob, err = messaging_api.NewMessagingApiBlobAPI(accessToken,\n    messaging_api.WithBlobHTTPClient(&http.Client{Timeout: lineBlobTimeout})) // 5 minutes\n```\n\nI gave the blob side 5 minutes because it needs to download videos sent by users.\n\nAlong with a more hidden trap. The SDK provides something that looks exactly like what I wanted:\n\n```\nfunc (call *MessagingApiAPI) WithContext(ctx context.Context) *MessagingApiAPI {\n    call.ctx = ctx\n    return call\n}\n```\n\nIt directly overwrites the field of a shared structure and then returns the same pointer. My `bot`\n\nis a package-level shared variable; if multiple requests come in simultaneously and each calls `WithContext`\n\n, 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`\n\n.\n\nThere was another one found in the same round. The `uploadParents`\n\nfunction is responsible for listing all `YYYY-MM`\n\nmonth folders, originally written like this:\n\n```\nr, err := srv.Files.List().Q(query).Fields(\"files(id)\").Context(ctx).Do()\n```\n\nNo `PageSize`\n\nset. The Drive API defaults to 100 items per page; anything beyond that requires asking again with a `nextPageToken`\n\n. 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`\n\n. There would be no error, no warning, just fewer results.\n\n**Reason and Solution**: Changed to use `Pages()`\n\nto 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`\n\nand then temporarily changed the implementation back to only fetch the first page to see if the test would fail:\n\n```\n--- FAIL: TestUploadParentsPagesThroughAllSubfolders\n    uploadParents() = [root_id month_1 month_2], want [root_id month_1 month_2 month_3]\n```\n\nConfirmed 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.\n\nFirst, the most direct numbers. Originally, `main.go`\n\nwas 1039 lines and `main()`\n\nwas 564 lines. After splitting into six files:\n\n| File | Lines | Responsibility |\n|---|---|---|\n`main.go` |\n94 | Startup, environment variable checks, routing |\n`config.go` |\n74 | Constants and shared state |\n`webhook.go` |\n344 | Event dispatching, command parsing, command handling |\n`line.go` |\n164 | LINE message assembly |\n`drive.go` |\n183 | Drive query/upload |\n`auth.go` |\n264 | OAuth, token, revocation |\n\n`main()`\n\nwent 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`\n\n.\n\nIn terms of performance, the search function was originally \"find the root folder, then check each monthly subfolder one by one,\" which is `1 + N`\n\nDrive API calls; after changing to concatenate all parents with `or`\n\ninto a single query, it's fixed at 2 calls.\n\n**The value of go-modern-guidelines isn't that it taught me syntax I'd never seen.** I generally knew about\n\n`cmp.Or`\n\n, `min`\n\n, and `slices.SortFunc`\n\n; 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.\n\nThis sentence is fighting the frequency bias mentioned earlier, and it applies to humans too.\n\n**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`\n\n—none of them were caught by `go-modern-guidelines`\n\n; 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.\n\n**The first half of this article was written before deployment.** The `/healthz`\n\npitfall was discovered after the article was finished and the PR merged, when I checked the build status on `gcloud`\n\n. The situation was: all 45 guidelines checked, everything applicable applied, 17 tests passed under `-race`\n\n, 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.\n\nI 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.\n\n**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.\n\nFinally, 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`\n\nis at [JetBrains/go-modern-guidelines](https://github.com/JetBrains/go-modern-guidelines), licensed under Apache 2.0.", "url": "https://wpnews.pro/news/go-in-practice-writing-modern-go-with-ai-testing-jetbrains-go-modern-guidelines", "canonical_source": "https://dev.to/gde/go-in-practice-writing-modern-go-with-ai-testing-jetbrains-go-modern-guidelines-and-refactoring-151o", "published_at": "2026-08-27 04:13:56+00:00", "updated_at": "2026-08-27 04:47:59.495673+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "artificial-intelligence"], "entities": ["JetBrains", "go-modern-guidelines", "Go"], "alternates": {"html": "https://wpnews.pro/news/go-in-practice-writing-modern-go-with-ai-testing-jetbrains-go-modern-guidelines", "markdown": "https://wpnews.pro/news/go-in-practice-writing-modern-go-with-ai-testing-jetbrains-go-modern-guidelines.md", "text": "https://wpnews.pro/news/go-in-practice-writing-modern-go-with-ai-testing-jetbrains-go-modern-guidelines.txt", "jsonld": "https://wpnews.pro/news/go-in-practice-writing-modern-go-with-ai-testing-jetbrains-go-modern-guidelines.jsonld"}}