How to Write Shitty Code in Go: Anti-Patterns and Examples

Go anti-patterns: real examples of bad code from production. Blocking channels in HTTP handlers, race conditions, deadlocks, broken encapsulation. How not to write Go code.
- tags
- #Go #Programming #Architecture
- categories
- Programming
- published
Modern developers love writing Go because it’s “fast” and “cool.” But you know what? You can write Go code so bad that legacy PHP from the 2000s starts looking like a work of engineering art.
Picture this: you run an email system. A mass mailing to 100k users. The backend starts choking, HTTP requests hang for a full minute, queues fill up to the ceiling, deadlocks start popping. You think “MySQL can’t keep up” or “RabbitMQ is slow” or “we need more servers.”
But what’s actually going on? The code is just written like garbage.
Below I’ll walk through real examples from a production system. This isn’t a tutorial on good code — these are anti-patterns that genuinely run in production. And yes, adults wrote this for money.
Logging metrics: blocking HTTP for no reason
We have an HTTP endpoint that’s supposed to log metrics. Write them to a channel, then batch them into the database. Sounds reasonable.
But here’s the question: what if the channel is full? Maybe drop the metric and move on? Maybe grow the buffer? Maybe just do a select with a default case?
Nah, fuck that. Let the HTTP request wait until we’ve written to the channel. Metrics matter more than the user, right?
type AssetLoadedService struct {
trackChan chan trackData // Buffer of 500, that'll be enough for everyone
}
func (this *AssetLoadedService) LogAsset(assetsInfo []requests.AssetInfo) {
for _, item := range assetsInfo {
// Blocking write - the user can wait
this.trackChan <- trackData{
url: item.Url,
contentType: item.ContentType,
size: item.Size,
mailbox: item.Mailbox,
}
}
}
// HTTP handler
func (this *MetricController) AssetLoaded(ctx *gin.Context) {
var body requests.AssetLoadedRequestBody
if err := ctx.ShouldBind(&body); err != nil {
this.error(ctx, common.NewHttpErrorValidationBadRequest(...))
return
}
// Request blocking? Who cares, users love waiting
this.assetLoadedService.LogAsset(body.AssetsInfo)
this.response(ctx, []string{})
}
What happens under heavy load? The 500-item buffer fills up in seconds, HTTP requests start hanging. A user opens an email and gets a timeout in response. Monitoring goes off. The logs say “context deadline exceeded.”
But hey, all the metrics got recorded! Beautiful.
Background jobs: race condition as a feature
We need background jobs. How? Redis Sorted Set? Try faking it with setTimeout on the frontend? RabbitMQ? Kafka?
Nah, fuck it. Let’s make a background_jobs table in MySQL 5.7, ancient as dinosaur shit, and select from it without transactions.
func (this *BackgroundJobManager) fetchingRoutine(pool int) {
jobChannel := make(chan models.BackgroundJobModel) // Unbuffered, because we can
for i := 0; i < this.config.PoolWorkerCount; i++ {
go this.workerRoutine(i, jobChannel)
}
for {
if backgroundJob, success := this.fetchJob(pool); success {
jobChannel <- backgroundJob // Blocking here too, why not
time.Sleep(jobFetchAfterJobDuration)
} else {
time.Sleep(jobFetchRetryDuration)
}
}
}
func (this *BackgroundJobRepository) Fetch(pool int) (backgroundJob models.BackgroundJobModel, err error) {
// SELECT without FOR UPDATE - who's going to stop us?
result := this.db.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}).
Where("pool = ? AND status = ? AND planned_at <= NOW()", pool, models.BackgroundJobStatusNew).
Order("planned_at ASC, created_at ASC, id ASC").
Limit(1).
Find(&backgroundJob)
// Then a separate UPDATE query - race conditions are just a theory
backgroundJob.Status = models.BackgroundJobStatusInProgress
backgroundJob.StartedAt = &twutils.DateTime{Time: time.Now()}
_ = this.backgroundJobRepository.Update(backgroundJob) // Errors? Never heard of them
return
}
What could possibly go wrong? Nothing, really! Except maybe:
| Time | Goroutine 1 | Goroutine 2 |
|---|---|---|
| T1 | SELECT job_id=42 WHERE status=new | |
| T2 | SELECT job_id=42 WHERE status=new | |
| T3 | UPDATE job_id=42 SET status=in_progress | |
| T4 | UPDATE job_id=42 SET status=in_progress | |
| T5 | Processes the job | Processes the same job 💀 |
Both goroutines grabbed the same job and are processing it twice. But that’s rare, right? Maybe we should use transactions and SELECT FOR UPDATE? Maybe we shouldn’t be hammering SELECT/UPDATE from two separate connections and turning the whole app into a single thread with a redis-lock?
Then why do we even need Go. Let’s just go back to PHP.
Fun fact: when the problems started, one developer suggested adding a rate limit of 5 messages per minute. As if the code isn’t bad, the load is just too high. GENIUS.
Architecture: the encapsulation we deserve
At the interview, they told us all about encapsulation and separation of concerns. We wrote it all down. And now we’re building a backend that reaches directly into microservices’ databases. Meanwhile, the microservices send HTTP requests to the backend.
Like two people rifling through each other’s pockets at the same time. Encapsulation!
// Backend goes straight into the spooler's DB
type DbConnectionManager struct {
connectionPull map[common.SpoolerIdentityName]*gorm.DB
}
func (this *DbConnectionManager) Get(spooler common.SpoolerIdentityName) (*gorm.DB, error) {
dbDsn := strings.ReplaceAll(this.config.DsnTemplate, "<spooler>", ip.String())
db, err = gorm.Open(mysql.Open(dbDsn), &gorm.Config{...})
return db, nil
}
// Backend reads data directly
func (this *MessageRepository) GetList(spooler common.SpoolerIdentityName, filter filters.MessageFilter) {
db, err := this.GetDB(spooler) // Direct access - it's faster
err = db.Clauses(allClauses...).Find(&messages).Error
}
Maybe build an HTTP API? Maybe don’t give the backend access to the spoolers’ databases? Maybe separate concerns?
Nah, fuck that. It’s 5ms slower. And we’re doing high load here.
Now we have:
- The backend knows the spooler’s DB schema (change a column — you have to change the backend too)
- 100 spoolers = 100 connection pools to the database
- Backend → spooler’s MySQL, spooler → HTTP backend (circular dependency)
- One SQL injection in the backend — every user leaked
But hey, it’s fast! Encapsulation — no idea what that is.
Delayed tasks: polling MySQL every 50ms
We need to run tasks with a 10-second delay, with the ability to cancel them. How do we do that?
Redis Sorted Set? Try faking it with setTimeout on the frontend? Rig up something in memory with channels?
Nah, fuck it. Let’s make a delayed_messages table in MySQL 5.7 and poll it every 50ms. Check planned_at <= NOW(), grab everything, process it.
func (this *DelayedMessageService) Process() {
ticker := time.NewTicker(50 * time.Millisecond) // Very optimal
for range ticker.C {
// SELECT without locks - it's faster
messages, _ := this.repo.GetDelayed()
for _, msg := range messages {
// Process each message
// If another goroutine is already processing it - oh well
this.ProcessMessage(msg)
}
}
}
What could go wrong? Who cares! MySQL can handle it. We only do 100k emails a day.
And what if you need to cancel a task? Just delete the row from the table. Two milliseconds pass between the SELECT and the processing — what could possibly be the problem?
Fighting deadlocks: a creative approach
We’ve got deadlocks. MySQL complains, transactions fail. What do we do?
Maybe look at SHOW ENGINE INNODB STATUS? Maybe check the lock order? Maybe add some indexes? Maybe use transactions instead of hammering SELECT/UPDATE/DELETE from two separate connections?
Nah, fuck that. Too complicated.
Let’s instead:
- Duplicate the DB connection (the old one is “blocked”)
- Spin up a local Redis client for a single function (the global one is “slow”)
- Add a Redis lock to avoid the DB deadlock (genius!)
- Slap on a rate limit of 5 requests per minute (blame the load)
// The original connection "was causing deadlocks"
db := this.dbConnectionManager.Get(spooler)
// A god-tier solution
newDb := gorm.Open(mysql.Open(dbDsn), &gorm.Config{...})
// Or spin up a local Redis client for one function
localRedis := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
mutex := localRedis.NewMutex("my-special-lock")
Now we have two connection pools to the same database, two Redis clients, and the deadlocks are still there, exactly as before. But hey, we did something!
Maybe just figure out the actual cause? Nah, that’s not for monkeys.
Help Dasha the PHP Developer Learn Go
This code was written by two PHP folks who decided they’d mastered Go in half a day. And to be fair, the syntax isn’t that complex! func, package, goroutine — all pretty simple.
In PHP projects, it’s usually either a complete shitshow (legacy code, copy-paste, alcoholism) or pristine code (Symfony/Laravel with DDD). There’s no middle ground.
And when PHP developers come to Go, they bring along:
thisas the receiver name — because it’s what they’re used to- 600 repositories for their libraries — as if there’s a composer here
- Redis locks to fight DB deadlocks — no idea where they even found that
- Formatting turned off — gofmt is for the weak
- Linters silenced — they get in the way of writing code
- Tests only on the frontend — the backend works, why test it
- No way to run it locally — you have to test on an external server, like the good old PHP 5 + FTP days. Straight to prod!
- A solid chunk of code typed as
any— screw type safety
Go gives you the tools to write good code: types, goroutines, channels, interfaces. But if you learn the language in half a day, you end up with code that runs worse than legacy PHP 5.3 without composer.
The funniest part is they’re completely convinced there’s nothing wrong with their code. The problem is “the load,” “the clients,” and so on. Come on. 400 rps isn’t load. No, buddy. The problem is that you wrote garbage.
A guide to writing shitty code
If you want to write code that makes everyone lose their minds, follow these rules:
- Block your HTTP handlers on channel writes. The user can wait
- Use MySQL as a queue — poll it every 50ms, no transactions. Race conditions are just a theory
- Talk a big game about encapsulation, but let the backend reach straight into microservices’ databases. It’s faster!
- Circular dependencies — backend → spooler’s DB, spooler → HTTP backend. A closed loop is beautiful
- Fight the symptoms — duplicate connections, add Redis locks, slap on rate limits. Just don’t figure out the actual cause
- Forget about transactions — they’re slow. SELECT, then UPDATE — that’s faster!
- Disable linters and formatting — they get in the way of creativity
- Learn the language in half a day and ship straight to production
- Test in prod — who needs a local environment?
thisin every receiver — you know why- Bail on the project at the right moment — ideally with a promotion on the way out
Conclusion
Labor turned apes into humans, but some apes didn’t put in the work and went off to write Go “high load” with “encapsulation” instead.
If you want to write good Go code, I recommend starting by studying best practices and avoiding the anti-patterns described here. And if you need help automating routine tasks, try the Cursor AI code editor , which can help with refactoring and improving your code.
P.S. All the code examples are from a real production project. Names have been changed, the stupidity has not. If you recognize your own code — go encapsulate something.