Your main.go is Doing Too Much - Refactor it
In early-stage application development, developers frequently write configuration loading, route registration, business logic, and server lifecycle management directly inside main.go. While this consolidation accelerates rapid prototyping, it quickly transforms the application's primary entry point into an unmaintainable, tightly coupled anti-pattern. The true architectural role of main.go is not to execute business operations, but to act as the central composition root—orchestrating and wiring decoupled internal packages together.
1. The Anti-Pattern: A Bloated main.go
When bootstrapping a web server in Go, developers often start by putting everything inside the main() function:
- Loading environment variables and configuration files.
- Instantiating HTTP routers and multiplexers.
- Declaring inline anonymous HTTP handler functions containing business logic and serialization logic.
- Configuring and starting network servers.
- Handling OS signals for graceful shutdown routines.
As the monolith adds features (e.g., product listings, authentication, payment processing), this single file expands rapidly. The result is high cognitive overhead, untestable inline closures, and cross-contamination of concerns.
Refactoring early—when the codebase has only a few endpoints—prevents technical debt from compounding. It is far simpler to establish clean separation of concerns with a single health-check endpoint than to untangle thousands of lines of interconnected handlers later.
2. Core Mental Model: main.go as a Switchboard
A useful mental model for main.go is an old-fashioned telephone switchboard:
The switchboard operator does not participate in the telephone conversation; their sole responsibility is plugging Cable A into Port B to connect the caller with the recipient.
Similarly, main.go should only:
- Initialize components (e.g., config, database connections, loggers).
- Wire dependencies into handlers and services.
- Pass references between decoupled modules.
- Listen for shutdown signals to terminate cleanly.
The actual execution of business logic, request validation, header manipulation, and payload serialization must always reside in domain-specific packages.
3. Package Structuring Patterns: Layer-Based vs. Feature-Based
Go does not enforce a rigid framework directory structure (unlike frameworks such as Rails or NestJS). Instead, architecture is determined by conventions adapted to the scale and complexity of the project. Two primary patterns dominate the Go ecosystem:
Pattern A: Layer-Based / Technical Structuring (MVC-style)
Files are grouped by their technical role in the application architecture:
handlers/orcontrollers/: HTTP/transport transport layer logic.services/: Core domain business rules.repositories/orstores/: Persistence layer and SQL operations.
When to use: Small to medium applications, simple services with 3–5 core entities, or early-stage monolith prototypes. It is immediately familiar to developers transitioning from frameworks like Express, Node.js, or Spring.
Pattern B: Feature-Based / Domain-Driven Structuring (Modular Monolith)
Files are grouped by bounded domain contexts:
health/: Contains its own HTTP handler, domain logic, and tests.users/: Contains user handler, service, storage, and data models.listings/: Contains listing management logic isolated from other packages.
When to use: Large enterprise codebases, systems maintained by multiple teams, or applications designed to be split into microservices later.
Structural Comparison
For a lightweight monolith managing a limited set of entities, Layer-Based Structuring provides clean boundaries without unnecessary structural overhead.
4. Enforcing Boundaries with the internal/ Directory
When creating auxiliary packages inside a Go project, where should they reside? Putting them in the project root creates an unintended issue: any other external Go module can import them.
To solve this, Go provides the internal/ directory convention, which is strictly enforced by the Go compiler:
Compiler Rules for internal/
- Any package inside a directory named
internal/can only be imported by packages rooted at the parent directory of thatinternal/directory. - If an external module attempts to run:
The Go compiler rejects the build:GO
import "github.com/myorg/myproject/internal/handlers"TEXTuse of internal package github.com/myorg/myproject/internal/handlers not allowed
Placing transport handlers in internal/handlers prevents outside projects from coupling to internal HTTP implementation details.
5. Exporting and Visibility Semantics in Go
In languages such as JavaScript/TypeScript, code visibility is controlled via the export keyword:
// TypeScript example (not Go)
export function healthHandler(req, res) { ... }
Go avoids visibility keywords entirely. Package-level encapsulation is determined solely by the casing of the first character of an identifier:
- Uppercase First Letter (
PascalCase): The identifier is exported (public) and accessible to packages outside its own. - Lowercase First Letter (
camelCase): The identifier is unexported (package-private) and accessible only within the same package.
Because main.go resides in package main and the handler resides in package handlers, the handler function must begin with a capital letter:
package handlers
// Health is exported because the identifier begins with a capital 'H'
func Health(w http.ResponseWriter, r *http.Request) {
// ...
}
6. Step-by-Step Code Evolution: Extracting the Health Handler
Step 1: The Naive Inline Implementation (main.go)
Initially, the handler is declared as an anonymous closure within main.go. This clutters the entry point with HTTP status codes, headers, and JSON strings:
// cmd/api/main.go (BEFORE REFACTORING)
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
// Anti-pattern: Handler logic declared directly inside main.go
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"available"}`))
})
server := &http.Server{
Addr: ":8088",
Handler: mux,
}
log.Printf("Starting server on port %s", server.Addr)
if err := server.ListenAndServe(); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}
Step 2: Creating the Dedicated Handler (internal/handlers/health.go)
Extract the handler logic into a modular, testable function under the handlers package.
// internal/handlers/health.go
package handlers
import (
"net/http"
)
// Health writes a basic JSON payload confirming system health.
// Must be capitalized to be exported to package main.
func Health(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"available"}`))
}
Step 3: Refactoring main.go to Wire the Handler
With the logic extracted, main.go simply imports the package and registers the function reference:
// cmd/api/main.go (AFTER REFACTORING)
package main
import (
"log"
"net/http"
// Import internal package via module path
"github.com/example/monolith/internal/handlers"
)
func main() {
mux := http.NewServeMux()
// main.go acts strictly as the composition root
mux.HandleFunc("/health", handlers.Health)
server := &http.Server{
Addr: ":8088",
Handler: mux,
}
log.Printf("Starting server on port %s", server.Addr)
if err := server.ListenAndServe(); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}
7. Function Reference vs. Function Execution
A common pitfall when registering handlers is confusing referencing a function with invoking a function:
// Correct: Passing a function reference as a callback value
mux.HandleFunc("/health", handlers.Health)
// Incorrect: Immediately invoking the function at startup
mux.HandleFunc("/health", handlers.Health(w, r))
Flow Breakdown: How Go Dispatches the Handler
Client (Browser / Insomnia)
|
| HTTP GET http://localhost:8088/health
v
net/http Server Engine
|
| Looks up route in ServeMux
v
ServeMux matches path: "/health"
|
| Executes stored function reference: handlers.Health(w, r)
v
internal/handlers.Health(w, r) executes
|
| Sets Header: Content-Type: application/json
| Writes Status: 200 OK
| Writes Body: {"status":"available"}
v
Response stream returned to Client
Because http.HandleFunc expects a signature of func(http.ResponseWriter, *http.Request), passing handlers.Health provides the function pointer that the Go HTTP runtime invokes whenever a matching request arrives.
8. Verification Workflow and Local Testing
After refactoring, always verify that your server compiles, routes match correctly, and HTTP response headers/payloads remain intact.
1. Build and Run via Makefile
Run the application using your configured build automation target:
make run
Console output confirms runtime port and startup sequence:
2026/03/30 14:15:02 Starting server on port :8088
2. Verify with Insomnia / cURL
Send an HTTP GET request to verify the decoupled handler:
curl -i http://localhost:8088/health
3. Expected Response Verification
HTTP/1.1 200 OK
Content-Type: application/json
Date: Mon, 30 Mar 2026 14:15:05 GMT
Content-Length: 22
{"status":"available"}
Confirming identical status codes, custom headers, and body output confirms that the behavioral contract was maintained throughout the refactor.
9. Real-World Practical Pitfalls & Scalability Considerations
-
Premature Over-Engineering: Do not split a 300-line monolith into 15 nested micro-packages with interfaces, DTOs, and abstract factory patterns prematurely. Grouping transport handlers under
internal/handlersis sufficient until domain complexity clearly demands sub-packages. -
The "God Handler" Anti-Pattern: Avoid placing every endpoint's logic into a single monolithic
handlers.gofile. As features are added, split files by resource within thehandlerspackage:Structure & Concept Flow -
Circular Import Dependencies (
import cycle not allowed): Go's compiler strictly prohibits circular dependencies. Ifpackage mainimportspackage handlers,package handlerscan never importpackage main.Similarly, if
handlersimportsservices, thenservicesmust never importhandlers. Dependency arrows must flow unidirectionally downward:
Summary and Key Takeaways
- The Role of
main.go: Acts strictly as the application switchboard and composition root. It handles configuration loading, dependency wiring, and lifecycle supervision, leaving business and transport logic to specialized packages. - Refactor Early: Decouple handlers while the codebase is small. Refactoring a single
/healthendpoint builds structural guardrails before database drivers, repositories, and complex business rules are added. - Directory Strategies: Choose Layer-Based Structuring (
internal/handlers) for simple applications with few entities, and reserve Feature-Based Structuring (internal/users,internal/listings) for larger modular monoliths. - The
internal/Boundary: Use Go's compiler-enforcedinternal/directory to prevent external modules from coupling to your private implementation details. - Visibility by Casing: Go does not have an
exportkeyword. Prefix function names with an uppercase letter (e.g.,func Health(...)) to expose them across package boundaries. - Function References vs. Calls: Pass the handler function identifier (
handlers.Health) rather than invoking it (handlers.Health()), allowingnet/httpto manage the request dispatch lifecycle. - Maintain Unidirectional Dependencies: Prevent Go compiler
import cycle not allowederrors by ensuring dependencies flow strictly downward frommainthrough transport, domain, and persistence layers.