DEV Community

Momchil Atanasov
Momchil Atanasov

Posted on

Go main-run pattern

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

There are a number of problems with this code.

  1. If you happen to forget os.Exit in some error branch, you will likely visit panic-land.
  2. The code gets bloated. I have seen code that has 10+ such checks in its main function.
  3. Any decent linter will complain that defer db.Close() might not get called since os.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
}
Enter fullscreen mode Exit fullscreen mode

With larger main functions, this is clearly better:

  1. There is only one place that has os.Exit so its hard to forget it.
  2. The code is much more compact and straight to the point.
  3. 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
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)