The most surprising thing about Monolith Clean Architecture is that it’s not about building a monolith; it’s about building a system so well-structured that if you ever needed to break it apart, the boundaries would already be clear and painlessly extractable.
Let’s see this in action with a simple user registration flow. Imagine we have a web API endpoint /users that accepts a POST request with user details.
// Request Body
{
"email": "test@example.com",
"password": "securepassword123",
"firstName": "Jane",
"lastName": "Doe"
}
Here’s how a "Clean" monolith would handle this, not by throwing everything into one giant file, but by organizing it into distinct layers with strict dependency rules.
src/
├── domain/
│ ├── entities/
│ │ └── user.go // User struct, business rules about users
│ ├── repositories/
│ │ └── user_repo.go // Interface for user persistence
│ └── services/
│ └── user_service.go // Business logic, orchestrates domain entities and repositories
├── infrastructure/
│ ├── persistence/
│ │ └── postgres_user_repo.go // Concrete implementation of UserRepo for PostgreSQL
│ └── web/
│ └── handlers/
│ └── user_handler.go // HTTP handlers, interacts with UserService
├── main.go // Application entry point, dependency injection
└── go.mod
The domain layer is the heart. It contains your core business entities (User) and abstract interfaces for anything that interacts with the outside world (like UserRepository). It knows nothing about HTTP, databases, or frameworks. The User entity might have validation logic:
// domain/entities/user.go
package entities
import "errors"
type User struct {
ID string
Email string
Password string // Hashed password
FirstName string
LastName string
}
func NewUser(email, password, firstName, lastName string) (*User, error) {
if !isValidEmail(email) {
return nil, errors.New("invalid email format")
}
if len(password) < 8 {
return nil, errors.New("password too short")
}
hashedPassword, err := hashPassword(password) // Assume hashPassword exists
if err != nil {
return nil, err
}
return &User{
Email: email,
Password: hashedPassword,
FirstName: firstName,
LastName: lastName,
}, nil
}
func isValidEmail(email string) bool {
// ... actual email validation logic ...
return true
}
func hashPassword(password string) (string, error) {
// ... actual password hashing logic ...
return "hashed_" + password, nil // Placeholder
}
The infrastructure layer implements the interfaces defined in the domain layer. PostgresUserRepository knows how to talk to a PostgreSQL database to save a User. UserHandler knows how to parse an incoming HTTP request and call the UserService.
The UserService in the domain layer orchestrates these. It receives a User entity, uses the UserRepository to save it, and potentially performs other business rules.
// domain/services/user_service.go
package services
import (
"your_module/domain/entities"
"your_module/domain/repositories"
)
type UserService struct {
userRepo repositories.UserRepository
}
func NewUserService(repo repositories.UserRepository) *UserService {
return &UserService{userRepo: repo}
}
func (s *UserService) RegisterUser(email, password, firstName, lastName string) (*entities.User, error) {
user, err := entities.NewUser(email, password, firstName, lastName)
if err != nil {
return nil, err
}
// Domain rule: Check if email already exists before saving
existingUser, err := s.userRepo.FindByEmail(email)
if err == nil && existingUser != nil {
return nil, errors.New("email already registered")
}
err = s.userRepo.Save(user)
if err != nil {
return nil, err
}
return user, nil
}
The main.go file is where dependencies are wired up. This is the only place where concrete implementations from infrastructure are allowed to know about interfaces from domain.
// main.go
package main
import (
"log"
"net/http"
"your_module/domain/services"
"your_module/infrastructure/persistence"
"your_module/infrastructure/web/handlers"
)
func main() {
// Dependency Injection
userRepo := persistence.NewPostgresUserRepository("postgres://user:password@host:port/dbname") // Real connection string
userService := services.NewUserService(userRepo)
userHandler := handlers.NewUserHandler(userService)
http.HandleFunc("/users", userHandler.HandleRegister)
log.Println("Server starting on :8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatalf("Could not start server: %s\n", err)
}
}
The core principle is dependency inversion. High-level modules (like domain/services) should not depend on low-level modules (like infrastructure/persistence). Both should depend on abstractions (domain/repositories). This makes your core business logic testable in isolation and allows you to swap out implementations (e.g., change from PostgreSQL to MySQL) without touching your domain code.
The one thing most people don’t realize is that "Clean Architecture" in a monolith isn’t about more files or more boilerplate; it’s about where you put the code and the strict rules about who can talk to whom. The domain layer is the "source of truth" and must remain pure, meaning it cannot import anything from infrastructure. All communication flows inward, towards the domain.
The next concept you’ll likely encounter is how to handle cross-cutting concerns like logging, authentication, and error handling in this layered structure.