Momchil Atanasov
Posted on September 12, 2023
I'll try to keep this short. It's just something I have seen way too many times and that has always resulted in poor code down the road.
Most example code online will have you write your main function as follows:
func main() {
client := somelib.NewClient("https://example.com")
err := client.DoSomething()
if err != nil {
log.Fatalf("Error: %v", err)
}
}
For a simple hello world example, this is fine. However, it then often leads to the following code:
func main() {
cfg, err := loadConfig()
if err != nil {
log.Fatalf("Error: %v", err)
}
db, err := openDB(cfg.DB)
if err != nil {
log.Fatalf("Error: %v", err)
}
defer db.Close()
client := somelib.NewClient(cfg.ClientURL)
err := client.DoSomething()
if err != nil {
log.Fatalf("Error: %v", err)
}
}
It gets even worse if you are using a custom logging package that doesn't have Fatalf
.
func main() {
cfg, err := loadConfig()
if err != nil {
log.Printf("Error: %v", err)
os.Exit(1)
}
db, err := openDB(cfg.DB)
if err != nil {
log.Printf("Error: %v", err)
os.Exit(1)
}
defer db.Close()
client := somelib.NewClient(cfg.ClientURL)
err := client.DoSomething()
if err != nil {
log.Printf("Error: %v", err)
os.Exit(1)
}
}
There are a number of problems with this code.
- If you happen to forget
os.Exit
in some error branch, you will likely visit panic-land. - The code gets bloated. I have seen code that has 10+ such checks in its main function.
- Any decent linter will complain that
defer db.Close()
might not get called sinceos.Exit
halts the program immediately and no cleanup is performed.
The main-run pattern
The main-run
pattern (not an official name, just something I have used to reference it throughout the years in internal discussions) is as trivial as it gets.
func main() {
if err := runApp(); err != nil {
log.Printf("Error: %v", err)
os.Exit(1)
}
}
func runApp() error {
cfg, err := loadConfig()
if err != nil {
return fmt.Errorf("error loading config: %w", err)
}
db, err := openDB(cfg.DB)
if err != nil {
return fmt.Errorf("error connecting to db: %w", err)
}
defer db.Close()
client := somelib.NewClient(cfg.ClientURL)
err := client.DoSomething()
if err != nil {
return fmt.Errorf("error calling client: %w", err)
}
return nil
}
With larger main functions, this is clearly better:
- There is only one place that has
os.Exit
so its hard to forget it. - The code is much more compact and straight to the point.
- The defer statements are run even on failure, ensuring linters are happy and proper cleanup is performed.
Signal handling
With this new design, there is also an elegant way to introduce a lifecycle context to the function.
func main() {
ctx, ctxDone := signal.NotifyContext(context.Background(), os.Interrupt)
defer ctxDone()
if err := runApp(ctx); err != nil {
log.Printf("Error: %v", err)
os.Exit(1)
}
}
func runApp(ctx context.Context) error {
...
return nil
}
Custom error codes
Ok, you might object, saying that the original design allows for custom exit codes.
To be honest, I have rarely seen an app that really needs that. But even if you do want custom exit codes, you can always use custom errors and a mapping function.
func main() {
ctx, ctxDone := signal.NotifyContext(context.Background(), os.Interrupt)
defer ctxDone()
if err := runApp(ctx); err != nil {
log.Printf("Error: %v", err)
os.Exit(exitCode(err))
}
}
var ErrConfigNotFound = errors.New("config not found")
var ErrDatabaseOffline = errors.New("database offline")
func exitCode(err error) int {
switch {
case errors.Is(err, ErrConfigNotFound):
return 2
case errors.Is(err, ErrDatabaseOffline):
return 3
default:
return 1
}
}
Summary
Well, that's pretty much it. Just something to keep in mind. And even if you don't like the proposed approach, use whatever option best suits you, just make sure to avoid the pitfalls that the os.Exit
approach has.
Posted on September 12, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 10, 2024