The growing pain of a single binary
You built a Go app that handles user signups and processes payments. It works great. Then you add inventory tracking. Then analytics. The main.go file grows. The build time creeps up. Two developers try to deploy changes at the same time and break each other's work. You hear the word "microservices" and wonder if splitting the code will fix everything. It might. It also might triple your operational headache. The decision isn't about hype. It's about where the pain actually lives.
Monolith vs microservices in plain words
A monolith is a single binary that does everything. All your code lives in one module, compiles together, and runs in one process. A microservice architecture breaks that binary into smaller services. Each service owns a specific piece of business logic and communicates over the network.
Think of a monolith like a Swiss Army knife. Everything you need is in one tool. It's easy to carry. If you need a screwdriver, you open the knife. Microservices are like a toolbox. You have a dedicated screwdriver, a hammer, and a wrench. You pick the right tool for the job, but you have to manage the whole box, and you can't use the hammer to cut wire.
Most editors run gofmt on save. Don't argue about indentation; let the tool decide. This convention keeps the codebase readable whether you have one file or a hundred.
A monolith is simple by default. Complexity comes from growth, not from the architecture itself.
Minimal monolith example
Here's a typical monolith layout. One module, multiple packages, single entry point.
package main
import (
"fmt"
"net/http"
)
// handleSignup processes a new user registration.
func handleSignup(w http.ResponseWriter, r *http.Request) {
// Validate input, save to DB, send welcome email.
// All logic runs in the same process.
fmt.Fprint(w, "User created")
}
// handlePayment processes a transaction.
func handlePayment(w http.ResponseWriter, r *http.Request) {
// Charge card, update ledger, notify user.
// Shares the same database connection pool.
fmt.Fprint(w, "Payment processed")
}
func main() {
// Register routes for both domains in one server.
http.HandleFunc("/signup", handleSignup)
http.HandleFunc("/pay", handlePayment)
// Start the single HTTP server.
http.ListenAndServe(":8080", nil)
}
In this code, handleSignup and handlePayment run in the same memory space. They share the same goroutine pool. If signup gets hammered, it can starve payment requests because they compete for the same CPU and memory. Data moves between functions via pointers or return values. No network latency. No serialization. If the database connection drops, both features fail instantly.
The verbose error handling pattern if err != nil { return err } is common in Go. The community accepts the boilerplate because it makes the unhappy path visible. You can't accidentally ignore an error.
What changes when you split
When you split, handlePayment moves to a different process. The signup service calls the payment service over HTTP or gRPC. Now they have separate memory. Separate databases. If signup crashes, payment keeps working.
But now you have network calls. Network calls fail. Network calls add latency. You need retries, timeouts, and circuit breakers. You need to handle partial failures where the signup succeeds but the payment times out. You need distributed tracing to follow a request across services.
The compiler won't catch a missing service. If you forget to import a package, you get undefined: pkg from the compiler. If you pass the wrong type to a function, the compiler rejects it with cannot use x as string value in argument. Runtime failures are different. If the payment service is down, your monolith returns an error to the user. You might see context deadline exceeded if a timeout fires, or dial tcp: lookup payment-service: no such host if DNS fails. These errors require handling logic that didn't exist in the monolith.
Context is plumbing. Run it through every long-lived call site.
Realistic split example
Here's what the monolith looks like after you extract the payment service. The monolith no longer processes payments. It delegates to the new service.
package main
import (
"context"
"fmt"
"net/http"
"time"
)
// callPaymentService sends a request to the external payment service.
func callPaymentService(ctx context.Context, amount int) error {
// Create a client with a timeout to prevent hanging on slow networks.
client := &http.Client{Timeout: 5 * time.Second}
// Build the request using the context for cancellation support.
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://payment-service:8080/charge", nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
// Execute the request and capture the response.
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("do request: %w", err)
}
// Close the body to release the underlying connection back to the pool.
defer resp.Body.Close()
}
// checkResponse validates the HTTP response from the payment service.
func checkResponse(resp *http.Response) error {
// Return an error if the status code indicates failure.
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("payment service returned %d", resp.StatusCode)
}
// Read the body to drain the connection and ensure completion.
_, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read body: %w", err)
}
return nil
}
// handlePayment delegates to the external service.
func handlePayment(w http.ResponseWriter, r *http.Request) {
// Pass the request context to respect client cancellation.
err := callPaymentService(r.Context(), 100)
if err != nil {
http.Error(w, "Payment failed", http.StatusBadGateway)
return
}
fmt.Fprint(w, "Payment delegated successfully")
}
func main() {
http.HandleFunc("/pay", handlePayment)
http.ListenAndServe(":8080", nil)
}
The context.Context parameter always goes first in Go functions. Name it ctx. Functions that take a context should respect cancellation and deadlines. This convention allows callers to control the lifetime of the operation.
When you split, you create a new module. This isolates dependencies.
# Initialize a new module for the payment service.
go mod init example.com/payment-service
# Add dependencies for gRPC or HTTP.
go get google.golang.org/grpc
The receiver name is usually one or two letters matching the type. Use (s *Service) not (this *Service). This keeps method signatures clean and follows Go style.
Pitfalls of distributed systems
Splitting introduces distributed system problems. The network is unreliable. You get timeouts. You get partial failures.
Data consistency becomes hard. In a monolith, you can use a database transaction to update the user and the payment in one atomic step. In microservices, each service has its own database. You need eventual consistency patterns like sagas or outbox patterns. If the payment succeeds but the inventory update fails, you have to compensate.
Observability gets complex. You can't just look at one log file. You need structured logging, metrics, and distributed tracing. A request might touch five services. You need a trace ID to follow it.
Deployment complexity grows. You used to deploy one binary. Now you deploy multiple services. You need a CI/CD pipeline for each. You need to manage versions. If service A calls service B, you have to ensure they are compatible.
Goroutine leaks happen when a goroutine waits on a channel that never gets closed. Always have a cancellation path. In microservices, a leaked goroutine in one service can exhaust resources and bring down the whole cluster.
Distributed systems are hard. Don't split until the pain of staying together outweighs the pain of splitting.
The modular monolith middle ground
You don't have to jump straight to microservices. A modular monolith gives you separation of concerns without network calls. You structure your code into packages that represent domains. You define interfaces between domains and implement them in separate packages.
Here's how you structure a modular monolith. You define interfaces between domains and implement them in separate packages.
package payment
import (
"context"
"database/sql"
)
// Service defines the contract for payment processing.
type Service interface {
// Charge processes a payment for the given amount.
Charge(ctx context.Context, amount int) error
}
// DBService implements Service using a database.
type DBService struct {
// db holds the database connection.
db *sql.DB
}
// Charge writes the transaction to the database.
func (s *DBService) Charge(ctx context.Context, amount int) error {
// Implementation details hidden behind the interface.
return nil
}
"Accept interfaces, return structs" is the most common Go style mantra. Functions should accept interfaces to allow flexibility, but return concrete structs to hide implementation details. This makes testing easier and keeps dependencies clear.
Public names start with a capital letter. Private names start lowercase. There are no keywords like public or private. This convention controls visibility without extra syntax.
Don't fight the type system. Wrap the value or change the design.
When to split and when to stay
Use a monolith when you are a small team building an MVP. Use a monolith when your deployment pipeline is slow and you want to ship features fast. Use a monolith when data consistency is critical and you need ACID transactions across domains.
Use microservices when you have multiple teams working on independent domains. Use microservices when specific parts of your app need to scale differently than others. Use microservices when you need to isolate failures so one domain doesn't bring down the whole system.
Use a modular monolith when you want separation of concerns without network calls. Use a modular monolith when you plan to split later but aren't ready for distributed complexity.
Start with a monolith. Split only when you have to.