1. Introduction
When you’re just starting out, packages can seem like folders “for neatness”: like, let it be tidy. But in Go, a package is not a folder and not a decorative frame—it’s a responsibility boundary and a contract for other parts of the code. Make a package inconvenient, and the whole application becomes inconvenient, because the other parts will start adapting to its weirdness.
Here’s a useful idea: everything you export from a package becomes part of the “promise” to others. If you later change that promise, you break clients. In Go this is taken very seriously: you can’t just “shift around” the public API of the standard library, otherwise other people’s programs that rely on it will break.
Package antipatterns are recurring ways to ruin the contract and the boundaries. Below we’ll cover three common groups of issues: global state, unnecessary dependencies, and unpredictable names/paths.
2. Global state and “global config”
Global state looks like a great time-saver: “Come on, I’ll just make var total int, and let functions change it.” In a small example, it works, and you feel like a winner. Then a week passes, you add a couple more functions, and suddenly total changes “by itself,” and you don’t understand where. That’s the classic story of convenience turning into a quest.
It’s important to catch this: the problem is not that the variable is “at the top of the file.” The problem is that it lives longer than you think, and many places can change it, which makes the reasons behind behavior blurry. The package turns into a room with one shared TV remote—and anyone can change the channel. Only the TV here is your logic.
Bad example: the calculator’s “hidden memory”
Let’s continue a learning mini-project: a console command calculator (like "add 2 3"). Imagine we decided to store the “last result” as a global variable.
package main
var lastResult int // global state: anyone can change it
func addToLast(x int) {
lastResult += x
}
It looks convenient: no need to pass the result around as parameters. But if tomorrow you add subFromLast, mulLast, resetIfNegative, and all of that is scattered across files—you’ll start debugging “why lastResult became 0” like an archaeologist: layer by layer.
Better: state is visible where it lives
A good alternative at your current level is to keep the state next to main() and pass it explicitly. Yes, that’s a few more characters. But those are honest characters.
package main
func add(a, b int) int {
return a + b
}
func main() {
last := 0
last = add(last, 10)
_ = last // from here you can print last via fmt.Println
}
Now you can open main.go and immediately see: “Okay, last starts at 0 and changes like this.” That’s not magic—it’s bookkeeping. In programming, bookkeeping is a compliment.
Why this is especially bad at the package level
When a global variable lives inside a package, you’re effectively doing this: “The package has hidden memory.” Then any import of that package starts depending on call history, not just on function input parameters. Meaning the package becomes not “a set of tools,” but “tools + a surprise.”
This problem gets worse when you start exporting such things outward. An exported variable is basically an “open window”: anyone can change it, and you no longer control the invariants.
Special case: “global config” via package-level var
A very common variation of global state is “let’s make global settings.” For example: var Debug = true, var DefaultPrecision = 2, var Separator = ",". At first it seems logical: the settings are shared, right? But it’s the same TV remote: anyone can change the settings in the middle of the program’s work.
In learning tasks this is still tolerable, but it’s a dangerous habit. Settings are better kept either as const (if it truly is a constant), or as a value you explicitly pass where it’s needed, or as a function parameter.
If the value must not change—use const
package main
const appName = "CalcCLI"
func main() {
_ = appName // safe: the constant won’t “drift”
}
If the value must change—let it live next to the logic
package main
func formatResult(n int, debug bool) string {
if debug {
return "DEBUG: result=" // then you can append the number via strconv.Itoa
}
return "result="
}
Here we’re not discussing “perfect architecture”; we’re locking in a basic idea: mutable state should be as local and explicit as possible. That alone sharply reduces the amount of mystery.
3. Unnecessary dependencies and imports that spread
An import in Go is not just “connect a library.” It’s an admission: “this file depends on this package.” The more imports, the harder it is to understand a file: you have more entities in your head, more context, more places where something can go wrong.
Go also trains discipline: unused imports are prohibited by the compiler. That’s good—trash doesn’t accumulate. But another problem remains: imports can be formally “used” while being conceptually unnecessary. For example, you drag fmt into your logic just for a debug Println(), and then “temporarily” leave it there forever.
Bad style: business logic prints by itself
In our calculator it’s easy to do this:
package main
import "fmt"
func add(a, b int) int {
fmt.Println("adding...") // debug output that “accidentally” became part of the behavior
return a + b
}
The problem is not only that it makes noise. The problem is that the add function now does two things: calculates and prints. That makes it harder to reuse and harder to test (even at the level of “mental testing”).
Better: logic calculates, main shows
package main
import "fmt"
func add(a, b int) int { // pure logic
return a + b
}
func main() {
fmt.Println(add(2, 3)) // 5
}
Yes, fmt is still needed, but it’s located where it belongs: at the boundary where you communicate with the user.
4. Contract and readability: API, names, and shadowing
This block is about how packages start to “smell a bit smoky” not because of syntax, but because of conventions: what you promise outward, what you name things, and how predictable it is to read.
Leaking implementation details through the API
If your package’s public functions return or accept types that are implementation details, you tie external code to those details. Then you’ll want to change the implementation—and you won’t be able to without pain.
We’re not going deep into interfaces and advanced API design yet, so let’s capture a common-sense rule: public package functions should talk to the outside world using simple, expected types (string, int, bool) and clear results. The fewer “specific” details stick out, the fewer traps you set for yourself.
The utils package: a “box of wires”
Names are not aesthetics—they are reading speed. Packages in Go are read constantly: you see fmt.Println, strconv.Atoi, and by one word you understand what’s happening. But if you name a package in a way that explains nothing, you force the reader to do extra work every time.
Almost every beginner eventually creates a utils package (or helpers, or common) and dumps everything into it. It looks like cleaning: “I put things in order, everything is in one place.” In reality it’s a drawer where you toss different wires. A month later you open the drawer and realize: there are more wires, and less order.
Bad news: utils doesn’t explain what’s inside.
Good news: this is fixed by meaningful naming.
Instead of a “how to do it right” list, here’s a small idea table (it’s easier to digest visually):
| Common name | Why it reads poorly | A more predictable alternative |
|---|---|---|
|
“everything is there” means “nothing specific” | parse, format, calc (by role) |
|
common for what? | config, constants (if that’s truly what it is) |
|
helpers for whom? | a domain-based name: tasks, users |
|
data is too broad | storage, input, output (by purpose) |
Even if you currently have only one main package, this logic applies to files and functions: name by meaning, not by mood.
Unpredictable import aliases
An alias is a good tool, but if you use it as “artistic style,” you’ll get something unclear.
package main
import f "fmt"
func main() {
f.Println("hi") // hi
}
This is allowed, but the question is: why? If you’re not solving a naming conflict and not making reading substantially better, you’re just giving the reader a riddle.
Shadowing package names
We already touched on shadowing (name masking) earlier as a “pitfall” of short declaration. In the context of packages it becomes especially unpleasant, because you can accidentally “erase” access to an imported package in the current block.
It looks like a bug of the “I didn’t do anything, it happened by itself” kind.
package main
import "fmt"
func main() {
fmt.Println("before") // before
fmt := "oops"
_ = fmt
// fmt.Println("after") // won’t compile: fmt is now a variable
}
This is not an “IDE quirk.” It’s an honest scope rule: inside the block, the name fmt is now taken by a variable. And that’s it—the package “disappears.”
A practical habit: don’t name variables after popular packages (fmt, rand, time, strings). It’s like naming a cat “Dog”: formally you can, but you’ll have lots of weird conversations later.
5. Practice: keeping a learning project tidy
At this stage of the course, our calculator is still very simple: we read a command, read numbers, perform an action, print the result. And that’s exactly why now is a good time to build discipline—so a simple application doesn’t become “simple chaos.”
A minimal-order idea without overcomplicating is: split code by meaning at least into files (not packages yet), and keep dependencies local.
For example, you can mentally lay out the project like this (this is not a “required structure,” but a way not to mix everything into one pot):
calccli/
├── main.go // input/output, user interaction
├── parse.go // parsing numbers and commands
└── ops.go // arithmetic (pure functions)
In Go, imports are declared at file level, and that’s convenient: ops.go may import nothing at all if it contains pure computations. This automatically protects you from unnecessary dependencies: if it’s not needed, it won’t appear.
Here’s what a small piece of “pure” arithmetic might look like in ops.go:
package main
func apply(op string, a, b int) int {
if op == "add" {
return a + b
}
return a - b
}
And separately, a piece of main.go that handles input and output:
package main
import "fmt"
func main() {
var op string
var a, b int
fmt.Scan(&op, &a, &b)
fmt.Println(apply(op, a, b))
}
There’s no “architecture” here, but there is already hygiene: logic doesn’t print by itself, there’s no global state, dependencies are minimal, and reading the file doesn’t turn into a 12-season series.
6. Common mistakes
Mistake #1: making a package-level var “just because it’s convenient.”
That’s usually how global state starts: “I’m just making one counter.” Then a second appears, then a third, then functions start changing them in different orders, and behavior becomes dependent on call history. The fix is simple: keep state next to main and pass it explicitly via parameters and results until you have a really strong reason to do otherwise.
Mistake #2: dragging imports deep into the logic just for fmt.Println and debug output.
This makes the logic noisy, and also makes the code harder to reuse. It’s better to return values upward and print in one place. Plus it reduces dependencies: a file with computations may import nothing at all.
Mistake #3: using import aliases “for looks.”
An alias is a tool, not a style. If you write f.Println instead of fmt.Println for no reason, you make reading harder. An alias is justified when there’s a naming conflict or when it truly makes the code clearer (for example, crand and mrand for two rand packages).
Mistake #4: creating utils/common/helpers as a trash bin.
At first it feels like order. Then it turns out to be a folder of riddles. Name parts of the code by meaning: parse, format, calc, or by domain. Even if you still keep everything in main, this rule is useful to apply to files and functions.
Mistake #5: accidentally shadowing an imported package name with a variable (fmt := "...").
It looks like “why did the package disappear?” In reality, you occupied the name in the current scope yourself. The habit is simple: don’t name variables after packages, and pay attention to what you write with := inside blocks.
GO TO FULL VERSION