A single application, often called a monolith, is surprisingly good at delivering features faster than a distributed system, provided you structure it correctly.

Let’s see what that looks like. Imagine a simple e-commerce backend.

package main

import (
	"database/sql"
	"encoding/json"
	"fmt"
	"log"
	"net/http"

	_ "github.com/lib/pq" // PostgreSQL driver
)

// User represents a customer.
type User struct {
	ID    int    `json:"id"`
	Name  string `json:"name"`
	Email string `json:"email"`
}

// Product represents an item for sale.
type Product struct {
	ID    int     `json:"id"`
	Name  string  `json:"name"`
	Price float64 `json:"price"`
}

// Order represents a customer's purchase.
type Order struct {
	ID        int `json:"id"`
	UserID    int `json:"user_id"`
	ProductID int `json:"product_id"`
	Quantity  int `json:"quantity"`
}

var db *sql.DB

func main() {
	// In a real app, this would be a proper connection string.
	connStr := "user=postgres password=password dbname=ecommerce sslmode=disable"
	var err error
	db, err = sql.Open("postgres", connStr)
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	// Ping the database to ensure the connection is valid.
	err = db.Ping()
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("Database connected successfully!")

	// API Endpoints
	http.HandleFunc("/users", getUsersHandler)
	http.HandleFunc("/products", getProductsHandler)
	http.HandleFunc("/orders", createOrderHandler)

	fmt.Println("Server starting on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

// getUsersHandler retrieves all users.
func getUsersHandler(w http.ResponseWriter, r *http.Request) {
	rows, err := db.Query("SELECT id, name, email FROM users")
	if err != nil {
		http.Error(w, fmt.Sprintf("Error querying users: %v", err), http.StatusInternalServerError)
		return
	}
	defer rows.Close()

	var users []User
	for rows.Next() {
		var u User
		if err := rows.Scan(&u.ID, &u.Name, &u.Email); err != nil {
			http.Error(w, fmt.Sprintf("Error scanning user row: %v", err), http.StatusInternalServerError)
			return
		}
		users = append(users, u)
	}

	if err := rows.Err(); err != nil {
		http.Error(w, fmt.Sprintf("Error after iterating users: %v", err), http.StatusInternalServerError)
		return
	}

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

// getProductsHandler retrieves all products.
func getProductsHandler(w http.ResponseWriter, r *http.Request) {
	rows, err := db.Query("SELECT id, name, price FROM products")
	if err != nil {
		http.Error(w, fmt.Sprintf("Error querying products: %v", err), http.StatusInternalServerError)
		return
	}
	defer rows.Close()

	var products []Product
	for rows.Next() {
		var p Product
		if err := rows.Scan(&p.ID, &p.Name, &p.Price); err != nil {
			http.Error(w, fmt.Sprintf("Error scanning product row: %v", err), http.StatusInternalServerError)
			return
		}
		products = append(products, p)
	}

	if err := rows.Err(); err != nil {
		http.Error(w, fmt.Sprintf("Error after iterating products: %v", err), http.StatusInternalServerError)
		return
	}

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

// createOrderHandler creates a new order.
func createOrderHandler(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	var order Order
	err := json.NewDecoder(r.Body).Decode(&order)
	if err != nil {
		http.Error(w, fmt.Sprintf("Error decoding request body: %v", err), http.StatusBadRequest)
		return
	}

	// In a real app, we'd add validation here (e.g., check if user and product exist)
	// and potentially use transactions for atomicity.
	_, err = db.Exec("INSERT INTO orders (user_id, product_id, quantity) VALUES ($1, $2, $3)",
		order.UserID, order.ProductID, order.Quantity)
	if err != nil {
		http.Error(w, fmt.Sprintf("Error creating order: %v", err), http.StatusInternalServerError)
		return
	}

	w.WriteHeader(http.StatusCreated)
	fmt.Fprintf(w, "Order created successfully")
}

This code is a single main.go file. It defines User, Product, and Order structs, connects to a PostgreSQL database, and exposes three API endpoints: /users, /products, and /orders. The /orders endpoint handles POST requests to create new orders.

The mental model for a well-structured monolith centers on modules. Think of your monolith as a collection of independent, well-defined libraries that happen to be deployed together. Each module has its own responsibilities, its own data structures, and its own internal logic. They communicate through clear, stable interfaces, not by reaching into each other’s internals.

In our example, even though it’s one file, we can conceptually see modules:

  • User Module: Handles user-related data and operations (though currently only retrieval).
  • Product Module: Manages product information.
  • Order Module: Deals with order creation and potentially other order-related logic.
  • Database Layer: Abstracted by the sql.DB object and the specific query functions.
  • API Layer: The http.HandleFunc registrations and their corresponding handler functions.

The key is separation of concerns. The getUsersHandler doesn’t need to know how Product data is stored; it only cares about fetching users. Similarly, createOrderHandler doesn’t need to understand the intricacies of user authentication; it assumes a valid UserID is provided.

Your primary lever is package organization. As your monolith grows, you’d break this single main.go into multiple packages:

cmd/
  myapp/
    main.go  // Entrypoint, orchestrates modules
internal/
  user/
    user.go       // User domain, structs, business logic
    repository.go // User data access
  product/
    product.go    // Product domain, structs, business logic
    repository.go // Product data access
  order/
    order.go      // Order domain, structs, business logic
    repository.go // Order data access
pkg/ // For shared, public libraries if needed
  util/
    logger.go

The cmd/myapp/main.go would then import these internal packages and wire them together. The internal/ directory signifies that these packages are only for use within this specific application.

The most powerful, yet often overlooked, aspect of monoliths is how easily you can refactor across module boundaries. In microservices, changing an API between services requires coordinating deployments and managing versioning. In a monolith, you can rename a field, change a function signature, and update all its callers in a single commit and deploy. This dramatically reduces the cost of evolution.

The next concept you’ll grapple with is how to manage configuration and secrets as your monolith’s responsibilities multiply.

Want structured learning?

Take the full Monolith course →