DEV Community

Cover image for main() Is the Most Important Function in Your Go Service
Gabriel Anhaia
Gabriel Anhaia

Posted on

main() Is the Most Important Function in Your Go Service


Open any Go service that has been running in production for more than a year. Scroll to main(). What do you see?

If the answer is three lines -- config.Load(), server.Start(), and a log statement -- someone hid the architecture. The real wiring lives in init() functions scattered across ten packages, in global variables that get mutated before main() even runs, or in a dependency injection framework that builds the object graph at runtime through reflection.

You cannot read the code and understand how the system is assembled. You have to run it, set breakpoints, and trace the execution to figure out what depends on what.

Open a hexagonal Go service where main() is treated as the composition root. Every adapter is created there. Every domain service receives its dependencies there. The entire dependency graph sits in one function, readable top to bottom.

That is the difference. And it changes how you build, test, and reason about Go services.

What a composition root actually is

The composition root is the single place in your application where all dependencies are created and wired together. It is not a pattern exclusive to Go. The term comes from Mark Seemann's work on dependency injection in .NET. But Go makes it trivially easy because Go has no constructors, no annotation processors, and no framework lifecycle hooks. You just call functions and pass arguments.

In a hexagonal architecture, the composition root is where the outside world meets the domain. You create the infrastructure (database connections, HTTP clients, message brokers), wrap them in adapter structs that satisfy your domain's port interfaces, and hand those adapters to your domain services.

The composition root is main(). Not a wire.go file. Not a container.go singleton. The function that the Go runtime calls first.

The real main() walkthrough

Here is a main() from a service that handles user registration. It sends welcome emails and stores users in PostgreSQL. Nothing exotic. The kind of service every team builds.

Infrastructure first -- the raw connections everything else depends on:

func main() {
    cfg := config.MustLoad()

    db := postgres.MustConnect(cfg.DatabaseURL)
    defer db.Close()

    smtpClient := smtp.NewClient(
        cfg.SMTPHost,
        cfg.SMTPPort,
        cfg.SMTPUser,
        cfg.SMTPPass,
    )
Enter fullscreen mode Exit fullscreen mode

Next, adapters. Each one wraps a piece of infrastructure and satisfies a domain port:

    userRepo := postgresadapter.NewUserRepository(db)
    emailSender := smtpadapter.NewWelcomeEmailSender(
        smtpClient,
        cfg.FromAddress,
    )
Enter fullscreen mode Exit fullscreen mode

The domain service receives those adapters as interfaces. Then the HTTP handler wraps the domain service for the outside world:

    registrationSvc := registration.NewService(
        userRepo,
        emailSender,
    )

    handler := httphandler.NewRegistration(registrationSvc)

    mux := http.NewServeMux()
    mux.HandleFunc("POST /users", handler.Register)
    mux.HandleFunc("GET /users/{id}", handler.GetByID)

    srv := &http.Server{
        Addr:         cfg.ListenAddr,
        Handler:      mux,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
    }

    log.Printf("listening on %s", cfg.ListenAddr)
    if err := srv.ListenAndServe(); err != nil {
        log.Fatalf("server stopped: %v", err)
    }
}
Enter fullscreen mode Exit fullscreen mode

Read it from top to bottom. You now know:

  • The service connects to PostgreSQL and an SMTP server.
  • Users are stored in Postgres via a repository adapter.
  • Welcome emails go through an SMTP adapter.
  • The registration.Service is the domain entry point. It depends on two interfaces: a user repository and an email sender.
  • A single HTTP handler exposes two endpoints.

No guessing. No grepping for init(). No reading a YAML file to understand what gets injected where.

The domain service does not know about main()

This is the part that matters. The registration.Service looks like this:

package registration

type UserRepository interface {
    Save(ctx context.Context, u User) error
    FindByID(ctx context.Context, id string) (User, error)
}

type WelcomeEmailSender interface {
    Send(ctx context.Context, to string, name string) error
}

type Service struct {
    users  UserRepository
    emails WelcomeEmailSender
}

func NewService(
    users UserRepository,
    emails WelcomeEmailSender,
) *Service {
    return &Service{
        users:  users,
        emails: emails,
    }
}
Enter fullscreen mode Exit fullscreen mode

The service defines its own interfaces and accepts them through NewService. The Register method uses those interfaces without knowing what sits behind them:

func (s *Service) Register(
    ctx context.Context,
    email string,
    name string,
) (User, error) {
    u := User{
        ID:    generateID(),
        Email: email,
        Name:  name,
    }

    if err := s.users.Save(ctx, u); err != nil {
        return User{}, fmt.Errorf(
            "saving user: %w", err,
        )
    }

    if err := s.emails.Send(ctx, u.Email, u.Name); err != nil {
        return User{}, fmt.Errorf(
            "sending welcome email: %w", err,
        )
    }

    return u, nil
}
Enter fullscreen mode Exit fullscreen mode

The service defines the interfaces it needs. It does not import database/sql. It does not import net/smtp. It does not know whether the email goes through SendGrid, Mailgun, or a mock that writes to a slice. That decision lives in main(), which is the only function that knows about the concrete world.

Why init() and globals break this

Go has an init() function that runs before main(). Every package can have one (or several). They run in dependency order, automatically, with no explicit call.

This sounds convenient. It is the opposite.

package database

var DB *sql.DB

func init() {
    var err error
    DB, err = sql.Open("postgres", os.Getenv("DATABASE_URL"))
    if err != nil {
        log.Fatal(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

Any package in your project can now do database.DB.Query(...). The dependency is invisible. Reading main() tells you nothing about it. The initialization order is out of your hands. Swapping the database in tests means setting environment variables or writing build tags. Running two instances with different configurations in the same process is off the table.

Global state hides wiring. Hidden wiring makes systems hard to test and harder still for new team members to understand.

A team I know had a service with over a dozen init() functions across different packages. When they needed to add a second database connection for a read replica, they spent longer than expected tracing which package initialized the connection, which other packages used the global, and what order everything had to happen in. In a composition-root service, that change is four lines in main().

Why DI containers are not the answer either

In Java and C#, dependency injection containers solve the wiring problem by scanning annotations, building an object graph at startup, and injecting dependencies through reflection. Spring, Guice, Autofac -- they all do this.

Go has DI tools too. Google's wire generates code. Uber's dig uses reflection at runtime. Facebook's inject does the same.

They work. But they solve a problem that Go does not have.

In Java, constructors are verbose. A class with six dependencies means six constructor parameters, a six-line assignment block, and a six-field declaration block. That is 18 lines of boilerplate before you write any logic. Annotations and containers exist to eliminate that pain.

In Go, a constructor is a function that takes arguments and returns a struct. The entire wiring for a complex service fits in 30-50 lines of main(). That is readable. That is greppable. That compiles to exactly the code you wrote, with no reflection layer between you and the runtime.

When you add a DI container to a Go project, you trade explicit wiring for implicit wiring. You gain nothing -- Go's boilerplate is already minimal -- and you lose the ability to read main() and see the whole system.

Testing gets trivially easy

When every dependency enters through a constructor parameter, testing is straightforward.

func TestRegister_SavesUserAndSendsEmail(t *testing.T) {
    repo := &fakeUserRepo{}
    sender := &fakeEmailSender{}
    svc := registration.NewService(repo, sender)

    user, err := svc.Register(
        context.Background(),
        "test@example.com",
        "Ada",
    )
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
Enter fullscreen mode Exit fullscreen mode

Call the service, get a result. Then assert the side effects:

    if repo.saved[0].Email != "test@example.com" {
        t.Errorf(
            "expected email test@example.com, got %s",
            repo.saved[0].Email,
        )
    }

    if sender.sent[0] != "test@example.com" {
        t.Errorf(
            "expected welcome email to test@example.com",
        )
    }

    if user.ID == "" {
        t.Error("expected user to have an ID")
    }
}
Enter fullscreen mode Exit fullscreen mode

No database. No SMTP server. No Docker. No environment variables. The test creates fake implementations of the ports, passes them to the service, and asserts behavior. It runs in microseconds.

Compare this with a service that uses database.DB as a global. To test it, you need a running PostgreSQL instance, you need to set DATABASE_URL, and you need to clean up test data between runs. Your test suite takes minutes instead of milliseconds.

The composition root pattern does not just make the architecture visible. It makes the architecture testable.

Adding a new adapter: one change in main()

Your team decides to switch from SMTP to SendGrid for transactional emails. In the composition-root approach, the change is surgical.

You write a new adapter:

package sendgridadapter

type WelcomeEmailSender struct {
    client *sendgrid.Client
    from   string
}

func NewWelcomeEmailSender(
    client *sendgrid.Client,
    from string,
) *WelcomeEmailSender {
    return &WelcomeEmailSender{
        client: client,
        from:   from,
    }
}

func (s *WelcomeEmailSender) Send(
    ctx context.Context,
    to string,
    name string,
) error {
    // build and send via SendGrid API
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Then you change main():

// Before
emailSender := smtpadapter.NewWelcomeEmailSender(
    smtpClient,
    cfg.FromAddress,
)

// After
sgClient := sendgrid.NewClient(cfg.SendGridAPIKey)
emailSender := sendgridadapter.NewWelcomeEmailSender(
    sgClient,
    cfg.FromAddress,
)
Enter fullscreen mode Exit fullscreen mode

The domain service does not change. The HTTP handler does not change. The tests do not change. You wrote a new adapter, swapped one line in the composition root, and the system works with a different email provider.

This is what "dependencies point inward" gives you in practice. The domain defines what it needs. The adapters provide it. main() picks which adapters to use. Nothing else needs to know.

The checklist for a clean main()

If you take one thing from this post, make it this checklist. Apply it to your next Go service.

1. Create infrastructure first. Database connections, HTTP clients, message broker connections. These are raw resources that adapters will wrap.

2. Build adapters. Each adapter wraps one piece of infrastructure and satisfies one domain port (interface). The adapter imports the infrastructure package. The domain does not.

3. Assemble domain services. Pass adapters into constructors. Each service receives only the interfaces it needs. No service receives the entire config or a god-object context bag.

4. Wire inbound adapters. HTTP handlers, gRPC servers, CLI commands, queue consumers. These call the domain services. They translate between the external protocol and the domain types.

5. Start the server. The final lines of main() start the HTTP listener, the gRPC server, or whatever your entry point is.

6. Zero globals. If you find yourself writing var db *sql.DB at package scope, stop. Pass it through main().

7. Zero init(). If you find yourself writing func init(), stop. Move that logic into main() where it is visible and controllable.

Every dependency in the system should be traceable by reading main() from top to bottom. If a new developer joins your team and reads that one function, they should be able to draw the dependency graph on a whiteboard. If they cannot, the wiring has leaked.

The dependency graph is the architecture

There is a reason main() matters more than any other function. It is the only function that sees the entire system. Domain services see their interfaces. Adapters see their infrastructure. Handlers see the services they call. But main() sees all of it.

That makes it the single source of truth for how your system is assembled. When you treat it with the same care you treat your domain logic -- keeping it explicit and readable, keeping it free of magic -- you get a service that is easy to understand and straightforward to change.

The composition root is not a Go pattern. It is a software architecture pattern. But Go, with its implicit interfaces and simple constructors, makes it so natural that there is no reason to reach for anything else.

Write your main() like it is documentation. Because it is.


If you found this useful

This post covers one slice of hexagonal architecture in Go: the composition root. The book goes deeper -- domain modeling, port design, adapter testing strategies, error handling across boundaries, the Unit of Work pattern for transactions, and how to migrate an existing service incrementally without a rewrite.

If you are building Go services and want them to stay maintainable past the six-month mark, the book walks through everything from a tangled main.go to a production-grade hexagonal service.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)