1. Naming as an interface to code
If you’ve ever opened someone else’s project and after five minutes felt like an archaeologist in a dusty temple full of traps, that’s almost never about algorithms—it’s about names. In Go this is especially noticeable because the language encourages simple constructs: less “magic” in the syntax means more responsibility on names. And yes, this is the moment when a programmer grows up: you start writing code not only for the computer, but also for a human (including “you in three months”).
Let’s agree on a practical criterion for a good name.
A good name is one after which you want to ask fewer questions.
A bad name is one after which you have to dig into the implementation to understand what it even does.
To make it easier to keep in mind, here’s a small table of “what the brain reads” (and why it gets tired):
| Code fragment | What happens in your head |
|---|---|
|
“Ok, we’re creating a task” |
|
“New what? what exactly? and what package is this?” |
|
“Ok, but why Create and not New? is there a difference?” |
|
“…what do these two values do, and why do I have to remember that?” |
Today we’ll cover three things that have the biggest impact on Go code readability: the export/unexport boundary, abbreviation style, and receiver names in methods.
2. Export and unexport: the package’s public contract
An exported name in Go is not “it looks cool with a capital letter.” It’s a literal promise: “this can be used from the outside, and I won’t break it without a good reason.” As soon as you make Task instead of task, you turn an internal detail into a public contract. And if you rename it later, external code will stop compiling.
That’s exactly why Go takes API compatibility so seriously: changing the type or name of a public entity means you may break package users. A good example of such a “contract” is the discussion of why you can’t casually change the type of os.Stdout: external code may depend on the concrete type.
A simple model: what you can change painlessly
Imagine a package as a small store. Exported things are the storefront and the checkout. Unexported things are the warehouse and the wiring. You can rearrange boxes in the warehouse however you like, but if you move the checkout into the restroom, customers (your users) will stop coming.
In our learning app (let’s call it todo), we want a simple model: the package provides clear types and functions to the outside, and hides the details inside.
Let’s look at a mini-skeleton of the todo package:
package todo
type Task struct {
ID int // exported: visible from outside
Title string // exported: visible from outside
done bool // unexported: internal detail
}
This structure already raises a question: should the done field really be hidden? Sometimes yes, sometimes no. The key is understanding the cost of the decision. If you export Done bool, then any external code can set task.Done = true bypassing your rules (for example, without writing the completion date, without updating counters, without running the required validation).
That’s why in Go it’s common to do this: fields that shouldn’t be changed directly are hidden, and access is provided via methods.
Here’s a more “contract-oriented” version:
package todo
type Task struct {
id int
title string
done bool
}
func (t Task) ID() int { return t.id }
func (t Task) Title() string { return t.title }
func (t Task) Done() bool { return t.done }
From the outside this reads like a normal API, and inside you have the freedom to change fields every Friday evening (if you enjoy dangerous entertainment).
Export affects serialization
This is an important nuance that often surprises beginners: some standard library mechanisms see only exported fields. For example, when encoding structs into some formats, only fields starting with a capital letter are considered. The gob documentation says explicitly: “only exported fields are encoded and decoded.”
Practical takeaway: if you want Task to be serializable “as-is,” the fields must be exported, or you’ll have to write separate data-transfer structs (which is normal practice, but today we’re only capturing the idea).
3. What to export and what to hide
When you’re just learning, it’s tempting to export everything: “maybe it’ll be useful.” This is like leaving all the wires sticking out of your computer case because “maybe I’ll want to plug in one more monitor.” As a result the case looks like a jellyfish, and it’s scary to touch.
In Go, the working rule is: export the minimum necessary, and only what truly constitutes the package contract.
Let’s say the todo package should be able to create a task and mark it as done. Then you can expose a constructor and a method:
package todo
import (
"errors"
"strings"
)
type Task struct {
id int
title string
done bool
}
func NewTask(id int, title string) (Task, error) {
title = strings.TrimSpace(title)
if id <= 0 {
return Task{}, errors.New("id must be positive")
}
if title == "" {
return Task{}, errors.New("title is empty")
}
return Task{id: id, title: title}, nil
}
func (t *Task) MarkDone() { t.done = true }
Pay attention to the names.
NewTask reads immediately as “create a valid task.”
MarkDone reads as “mark as done.”
And importantly, we do not expose direct access to the fields, so external code can’t break invariants.
Now, if we decide that done is not just a bool but also the completion time, we can change the internals without rewriting other people’s code. But if we had exported Done bool, external code could write t.Done = true directly, and you’d later chase bugs like “it’s done, but the time isn’t set.”
4. Abbreviations: ID, URL, HTTP, and others
Abbreviations feel like a small thing—right up until you see a project that has userId, UserID, userID, UserId, and USERID all at once. Then comes a fun quest: IDE autocomplete shows five variants, tests fail because of serialization, and you think, “maybe I’ll just go live in the woods and compile sticks.”
In Go, it’s customary to write common abbreviations in all caps inside CamelCase. This is not pedantry; it’s a way to make code predictable when reading.
Here’s a mini table that usually covers 80% of real cases:
| Bad | Better | Why |
|---|---|---|
|
|
reads as a single abbreviation |
|
|
is a standard abbreviation |
|
|
reads as a protocol, not as a name like “Httpp” |
Now let’s apply this to our todo domain. Entities often have an identifier. If you introduce a custom type, write it like this:
package todo
type TaskID int
Then use it in the struct (if you need a stricter type than just int):
package todo
type Task struct {
id TaskID
title string
done bool
}
Names like TaskId look harmless, but they make the code less “scannable”: your eye catches on it because the expectation in the Go ecosystem is different.
Abbreviations in local variables vs in the API
With abbreviations there’s a fine line: in local variables you can be a bit less formal (but don’t turn code into a puzzle), while in public names it’s better to be strict, because that’s a contract.
Compare:
package main
import "fmt"
func main() {
userID := 10
fmt.Println(userID) // 10
}
Here userID is fine: it’s a local variable, the context is nearby, and the reader doesn’t suffer.
But in a public field/method, it’s better not to do this:
package todo
type Task struct {
TaskId int // questionable: in Go people usually expect TaskID
}
The idea is simple: the wider the visibility of a name, the more people and code will look at it, and the more important predictability becomes.
5. Receivers: short and unambiguous
A receiver is a method parameter, just with special syntax. And in Go its name is visible constantly: in every method, in every line where you write t.title or s.store. That’s why the receiver should be short and stable; otherwise it turns into visual noise.
There are two extremes.
The first is naming the receiver this or self, like in other languages. In Go that looks foreign and usually just gets in the way: the receiver is not a magic word, it’s an ordinary variable.
The second is giving the receiver an overly “smart” name, such as taskService or currentTodoTask. Then every line becomes longer and harder to read.
Rule: short, unambiguous, stable
Let’s take our Task. A logical receiver is t:
package todo
func (t Task) Done() bool { return t.done }
func (t *Task) MarkDone() { t.done = true }
There’s no “philosophy” here: t = task. Short, clear, doesn’t interfere.
For Service, people often use s:
package todo
type Service struct{}
func (s Service) Name() string { return "todo-service" }
And here’s an example that illustrates the receiver as “just a normal parameter,” where the receiver can even be a function: in one of the classic Go materials, a handler uses the receiver fn because it’s a function, and the code reads naturally: “call fn.”
The receiver should not dominate the meaning
Let’s compare two variants. The first is formally understandable, but heavy:
package todo
type Task struct{ done bool }
func (task *Task) MarkDone() { task.done = true }
It seems fine… but if you have ten methods, the word task will repeat everywhere and clutter the screen.
The second variant is shorter and usually reads better:
package todo
type Task struct{ done bool }
func (t *Task) MarkDone() { t.done = true }
At the same time, we don’t lose meaning: the type Task is already written in the method signature, so the reader already knows t is a task.
When the first letter doesn’t save you
Sometimes you’re tempted to name the receiver after the first letter of the type. This often works, but there are cases where the first letter is not unique or looks odd.
Imagine types Task and Tag. The receiver t fits both—and now you get confusion in a file where both types appear. The solution isn’t universal, but it’s practical: either keep these types in different files/contexts, or choose different receivers, for example task and tag (locally in one place, that’s acceptable).
What matters is not the rule, but the effect: when reading a method it should be clear what the receiver refers to, and it should not visually dominate the essence.
6. Micro-refactoring: improving names in todo
Now we’ll do a “before/after” exercise right on the code, but without the feeling of “here comes a morality lecture.” Let’s imagine we had rough code written in a hurry:
package todo
type task struct {
id int
name string
isOk bool
}
func New(id int, name string) task {
return task{id: id, name: name}
}
func (this *task) Do() {
this.isOk = true
}
Technically, this compiles. But naming-wise, it has a whole set of issues.
First, the type task is not exported, while New is exported—odd: we expose a function that returns an internal type (that’s almost always a bad sign).
Second, name in the task domain is more often called title or text, and isOk doesn’t say what happened at all. “Ok” what?
Third, Do() doesn’t reveal meaning: do what?
Fourth, the receiver this is stylistically not Go-like.
Let’s bring this to a more readable contract:
package todo
import "errors"
type Task struct {
id int
title string
done bool
}
func NewTask(id int, title string) (Task, error) {
if id <= 0 {
return Task{}, errors.New("id must be positive")
}
return Task{id: id, title: title}, nil
}
func (t *Task) MarkDone() { t.done = true }
And even without reading the implementation, the names already show: we have a Task, it has done, we can create it via NewTask and mark it done via MarkDone. That’s the goal of naming: so the meaning is visible before the details.
Cheat-sheet diagram: how a name passes the filter
To make decisions easier, it helps to have a “lazy algorithm” for checking a name in your head. It’s not perfect, but it saves you from many random names like tmp2 and dataX.
flowchart TD
A["Came up with a name"] --> B{"Is the name visible outside the package?"}
B -- "Yes" --> C["Make it more precise: this is a contract. Consider initialisms (ID/URL)"]
B -- "No" --> D["Can be shorter, but not a puzzle. Is the context nearby?"]
C --> E{"Does the name read like a phrase in code?"}
D --> E
E -- "Yes" --> F["Keep it"]
E -- "No" --> G["Rename: clarify the goal, remove extra words"]
Yes, it looks like bureaucracy, but it’s literally two seconds before you “preserve” a name in code for years.
7. Common mistakes
Mistake #1: exporting “just in case.”
This is a very common habit: make Task, TaskManager, TaskProcessor, TaskHelper—all capitalized—so it’s “available.” The problem is that this turns internals into a contract. Later you’ll want to change the data structure, and external dependencies won’t let you. It’s better to start with minimal export and expand the API only when there is a real user of that API (even if that user is another package inside your project).
Mistake #2: mixing abbreviation styles within one project.
When in one place it’s UserID, in another it’s UserId, and in a third it’s user_id, you create cognitive noise: it’s harder for the eye to scan code, IDEs have a harder time suggesting, and data formats may produce surprises. In Go style, you typically choose ID, URL, HTTP and stick to it everywhere—especially in public names.
Mistake #3: making receivers “descriptive” at the cost of verbosity.
Receivers like currentTask, taskInstance, serviceObject sometimes seem “more understandable,” but in practice they clutter every line. In Go, the type is expected to carry the meaning, while the receiver should not get in the way. That’s why t, s, c often win—until there’s real ambiguity.
Mistake #4: naming methods with meaningless verbs (Do, Process, Handle) where you need specificity.
A method named Do() is almost always a bad sign because it explains nothing. In a task domain, MarkDone, Rename, Delete, ToggleDone are much clearer. A verb in a name is good—but only if it describes the action rather than replacing the thought.
Mistake #5: making names “technical” rather than domain-driven.
For example, str1, arr2, flagX, data. Such names describe shape, not purpose. It’s better when the name carries a role: title, taskID, done, createdAt (if you truly need it). Technical names are acceptable over a very short distance (two lines of code), but the longer a variable/type lives, the more important a meaningful name becomes.
GO TO FULL VERSION