Behind the “Spaghetti Code” in a Fintech Project
When I first joined a Fintech project written in Go, I was “heat-shocked” reading the codebase. The project had been running for over a year with five developers. Payment processing, DB connections, and notification logic were all tangled together like a spider web. Every time we needed to add a new payment gateway like Momo or ZaloPay, the team had to struggle through copying and pasting dozens of nested if-else lines.
The consequences were immediate. Writing unit tests became a nightmare because components were tightly coupled. Once, I only fixed a display bug in the notification module, but somehow it crashed the entire payment flow. This is the price you pay for disregarding Design Patterns and thinking that just writing simple Go is enough.
Why Does Go Still Need Design Patterns?
Many people believe that because Go emphasizes simplicity, Java or C++ patterns are redundant. In reality, it’s the opposite. When a project exceeds 10,000 lines of code, without a standard framework, your system will soon become a pile of technical debt.
During the refactoring of that project, I applied three key patterns: Singleton, Factory, and Strategy. The results were impressive. The speed of developing new features increased significantly, and more importantly, the team was no longer afraid of Friday afternoon deployments.
1. Singleton Pattern: Centralized Resource Management
The Problem: Database Connections Reaching Their Limit
In the old project, developers often initialized new connections arbitrarily or used uncontrolled global variables. When traffic hit 1,000 requests per second, the system immediately threw too many connections errors due to resource leaks.
The Solution: Implementation with sync.Once
In Go, don’t use init() to create a Singleton. The standard way is using sync.Once. This mechanism ensures that the initialization function runs exactly once, regardless of how many goroutines are competing.
package database
import (
"sync"
"database/sql"
_ "github.com/lib/pq"
)
type dbInstance struct {
conn *sql.DB
}
var (
instance *dbInstance
once sync.Once
)
func GetInstance() *dbInstance {
once.Do(func() {
// Initialize a single Postgres connection
db, _ := sql.Open("postgres", "user=admin dbname=fintech sslmode=disable")
db.SetMaxOpenConns(25) // Limit connection pool
instance = &dbInstance{conn: db}
})
return instance
}
Thanks to this approach, I could accurately control the number of connections. Memory leaks and DB hangs completely disappeared.
2. Factory Method: Encapsulating Initialization Logic
The Problem: Multi-channel Notification System
Initially, every time a message needed to be sent, the team’s code was flooded with logic to check the notification type. Every time a new channel like Telegram or Viber was added, the team spent all morning reviewing and fixing if-else statements across multiple files.
The Solution: Using Interfaces as Blueprints
I defined a common interface and a Factory to separate object creation logic from business logic.
type Notification interface {
Send(message string)
}
type EmailNotification struct{}
func (e EmailNotification) Send(msg string) { /* SendGrid API logic */ }
type SMSNotification struct{}
func (s SMSNotification) Send(msg string) { /* Twilio API logic */ }
func NewNotification(notifyType string) Notification {
switch notifyType {
case "email":
return EmailNotification{}
case "sms":
return SMSNotification{}
default:
return nil
}
}
Now, the sending logic is encapsulated in a single line: NewNotification("email").Send("OTP Verification"). The code is much cleaner.
3. Strategy Pattern: Flexibly Changing Algorithms
The Problem: The Shipping Fee Matrix
Each shipping partner (GHN, Viettel Post, Grab) has its own way of calculating fees based on weight and distance. If written into a single function, that function would be hundreds of lines long and extremely difficult to understand.
Real-world Implementation
Each shipping unit is a “Strategy.” You can swap them out like changing clothes without affecting the body (the Order object).
type ShippingStrategy interface {
Calculate(weight float64) float64
}
type GHNStrategy struct{}
func (g GHNStrategy) Calculate(w float64) float64 { return w * 1.2 }
type GrabStrategy struct{}
func (g GrabStrategy) Calculate(w float64) float64 { return w * 2.5 }
type Order struct {
weight float64
strategy ShippingStrategy
}
func (o *Order) SetStrategy(s ShippingStrategy) {
o.strategy = s
}
func (o *Order) GetShippingCost() float64 {
return o.strategy.Calculate(o.weight)
}
When my manager asked to add a new shipping provider, it only took me 5 minutes to create a new struct. No need to touch the old code, ensuring the Open/Closed principle.
Key Takeaways After 6 Months of Real-world Operation
Applying patterns isn’t about “showing off.” It helps the system survive when scaling. Here are three small tips for you:
- Avoid Over-engineering: If the logic is simple, don’t try to force a Factory into it. Only use it when you truly see repetition.
- Small interfaces are best: In Go, keep interfaces lightweight, usually with only 1 or 2 methods.
- Easy Mocking: By separating interfaces, mocking third-party services when writing Unit Tests becomes incredibly simple.
Mastering Design Patterns helps you shift from a “write to run” mindset to “write to maintain.” Try applying them to your smallest task today, and you’ll see the difference.

