Right, let’s get our hands dirty. Structuring a Go microservice isn’t about picking the fanciest framework; it’s about applying Go’s philosophy of simplicity and explicitness to a distributed system. We’re going to build a service that’s easy to reason about, test, and—crucially—throw into a mesh later. Forget the 50-file boilerplate generators; we’re going to do this the direct way.

First, the absolute non-negotiable: your main.go is not your application. It’s the entry point to your application. Its job is to parse flags, read config, wire up dependencies, and start the servers. That’s it. If it’s more than 30-40 lines, you’re probably doing too much in there.

The cmd/ Directory is Your Best Friend

Your project root should have a /cmd directory. Inside that, a directory for each binary your project produces (e.g., /cmd/my-service, /cmd/migrate-tool). Each has its own main.go. This keeps a clear separation of concerns. One service, one entry point.

my-service/
├── cmd/
│   └── my-service/
│       └── main.go
├── internal/
│   ├── handler/
│   ├── service/
│   └── store/
├── pkg/
└── go.mod

internal/ for… Well, Internal Things

Use the internal directory. Go’s toolchain enforces that code outside your project cannot import code inside internal. This is your best defense against creating accidental public APIs and the coupling nightmare that follows. Your database models, your business logic, your HTTP handlers—they all live in here, safe from prying eyes.

The Handler-Service-Repository Pattern

This is less about dogma and more about creating sensible boundaries. You’ll thank me during testing.

  • Handler (or Transport Layer): Deals with HTTP/gRPC specifics. Its job is to marshal/unmarshal JSON/protobuf, validate incoming requests, and translate them into calls to the…
  • Service (Business Layer): This is the heart of your microservice. It contains the actual business logic, completely unaware of HTTP or databases. It gets interfaces to a database and other dependencies passed into it.
  • Repository (Data Access Layer): The concrete implementation of data storage. It talks to PostgreSQL, MongoDB, or a third-party API. It’s defined by an interface in the service layer so you can mock it for tests.

Here’s how this looks in practice. First, define the interface for your data store in your service package:

// internal/service/user.go
package service

import "context"

type UserStore interface {
	GetUserByID(ctx context.Context, id int) (*User, error)
}

type UserService struct {
	store UserStore
}

func NewUserService(store UserStore) *UserService {
	return &UserService{store: store}
}

func (s *UserService) GetUserProfile(ctx context.Context, id int) (*UserProfile, error) {
	// Business logic lives here. Maybe check permissions?
	user, err := s.store.GetUserByID(ctx, id)
	if err != nil {
		return nil, err
	}
	// ... other logic ...
	return &UserProfile{Name: user.Name}, nil
}

Now, the concrete implementation, say, for Postgres:

// internal/store/postgres/user.go
package postgres

import (
	"context"
	"database/sql"
	"your-project/internal/service"
)

type UserRepo struct {
	db *sql.DB
}

func (r *UserRepo) GetUserByID(ctx context.Context, id int) (*service.User, error) {
	// Actual SQL query here
	row := r.db.QueryRowContext(ctx, "SELECT name FROM users WHERE id = $1", id)
	var user service.User
	err := row.Scan(&user.Name)
	if err != nil {
		return nil, err
	}
	return &user, nil
}

Finally, the HTTP handler that glues it all together:

// internal/handler/http/user.go
package http

import (
	"encoding/json"
	"net/http"
	"strconv"
	"your-project/internal/service"
)

type UserHandler struct {
	userService *service.UserService
}

func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
	idStr := r.PathValue("id")
	id, err := strconv.Atoi(idStr)
	if err != nil {
		http.Error(w, "invalid ID", http.StatusBadRequest)
		return
	}

	user, err := h.userService.GetUserProfile(r.Context(), id)
	if err != nil {
		// Actually handle errors properly here, maybe with a helper
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(user)
}

The magic happens in main.go where we wire it all together:

// cmd/my-service/main.go
package main

import (
	"database/sql"
	"log"
	"net/http"
	"your-project/internal/handler/http"
	"your-project/internal/service"
	"your-project/internal/store/postgres"

	_ "github.com/lib/pq"
)

func main() {
	// 1. Open DB connection
	db, err := sql.Open("postgres", "connection-string")
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	// 2. Instantiate the concrete repository
	userRepo := &postgres.UserRepo{DB: db}

	// 3. Instantiate the service, injecting the repository (via its interface)
	userService := service.NewUserService(userRepo)

	// 4. Instantiate the handler, injecting the service
	userHandler := &http.UserHandler{UserService: userService}

	// 5. Set up routes
	mux := http.NewServeMux()
	mux.HandleFunc("GET /user/{id}", userHandler.GetUser)

	// 6. Start server
	log.Println("Server starting on :8080")
	log.Fatal(http.ListenAndServe(":8080", mux))
}

See what we did? The service knows nothing about HTTP. The repository knows nothing about HTTP. They just know about contexts, errors, and business objects. This is infinitely testable.

Configuration: No More Global Variables

Pass your dependencies explicitly. No global database connection pools, no global loggers. Your main function is the one place where it’s okay to have a little wiring chaos. Use a struct to hold your config, parsed from environment variables or a file. I’m a fan of the github.com/kelseyhightower/envconfig package for this. It keeps the insanity contained.

type Config struct {
	DBHost     string `envconfig:"DB_HOST" default:"localhost"`
	DBPort     int    `envconfig:"DB_PORT" default:"5432"`
	ServerPort int    `envconfig:"PORT" default:"8080"`
}

func main() {
	var cfg Config
	err := envconfig.Process("", &cfg)
	if err != nil {
		log.Fatal(err)
	}
	// Use cfg.DBHost, etc.
}

This structure isn’t just academic. It creates a clean, testable, and maintainable codebase that can easily be integrated with a service mesh, because the core business logic is completely isolated from the mechanics of transportation and discovery. You’re already winning, and we haven’t even gotten to the mesh part yet.