Go Architecture Patterns Series: ← Previous: Domain-Driven Design | Series Overview | Next: Microservices Architecture →
What is Modular Monolith Architecture?
Modular Monolith Architecture is an approach that combines the simplicity of monolithic deployment with the modularity of microservices. It organizes code into independent, loosely coupled modules with well-defined boundaries, all deployed as a single application.
Key Principles:
- Module Independence: Each module is self-contained with its own domain logic
- Clear Boundaries: Modules communicate through well-defined interfaces
- Shared Deployment: All modules deployed together in a single process
- Domain Alignment: Modules organized around business capabilities
- Internal APIs: Modules expose APIs for inter-module communication
- Data Ownership: Each module owns its data and database schema
Architecture Overview
Module Communication Patterns
Real-World Use Cases
- E-commerce Platforms: Product, order, inventory, and payment management
- SaaS Applications: Multi-tenant applications with distinct features
- Content Management Systems: Content, media, user, and workflow modules
- Banking Systems: Account, transaction, loan, and reporting modules
- Healthcare Systems: Patient, appointment, billing, and medical records
- Enterprise Applications: HR, finance, inventory, and CRM modules
Modular Monolith Implementation
Project Structure
├── cmd/
│ └── app/
│ └── main.go
├── internal/
│ ├── modules/
│ │ ├── user/
│ │ │ ├── domain/
│ │ │ │ ├── user.go
│ │ │ │ └── repository.go
│ │ │ ├── application/
│ │ │ │ └── service.go
│ │ │ ├── infrastructure/
│ │ │ │ └── postgres_repository.go
│ │ │ ├── api/
│ │ │ │ └── http_handler.go
│ │ │ └── module.go
│ │ ├── order/
│ │ │ ├── domain/
│ │ │ │ ├── order.go
│ │ │ │ └── repository.go
│ │ │ ├── application/
│ │ │ │ └── service.go
│ │ │ ├── infrastructure/
│ │ │ │ └── postgres_repository.go
│ │ │ ├── api/
│ │ │ │ └── http_handler.go
│ │ │ └── module.go
│ │ ├── product/
│ │ │ ├── domain/
│ │ │ │ ├── product.go
│ │ │ │ └── repository.go
│ │ │ ├── application/
│ │ │ │ └── service.go
│ │ │ ├── infrastructure/
│ │ │ │ └── postgres_repository.go
│ │ │ ├── api/
│ │ │ │ └── http_handler.go
│ │ │ └── module.go
│ │ └── payment/
│ │ ├── domain/
│ │ │ ├── payment.go
│ │ │ └── repository.go
│ │ ├── application/
│ │ │ └── service.go
│ │ ├── infrastructure/
│ │ │ └── postgres_repository.go
│ │ ├── api/
│ │ │ └── http_handler.go
│ │ └── module.go
│ └── shared/
│ ├── database/
│ │ └── postgres.go
│ └── events/
│ └── event_bus.go
└── go.mod
Module 1: User Module
// internal/modules/user/domain/user.go
package domain
import (
"context"
"errors"
"time"
)
type UserID string
type User struct {
ID UserID
Email string
Name string
Active bool
CreatedAt time.Time
UpdatedAt time.Time
}
var (
ErrUserNotFound = errors.New("user not found")
ErrUserAlreadyExists = errors.New("user already exists")
ErrInvalidEmail = errors.New("invalid email")
)
// Repository defines the interface for user storage
type Repository interface {
Create(ctx context.Context, user *User) error
GetByID(ctx context.Context, id UserID) (*User, error)
GetByEmail(ctx context.Context, email string) (*User, error)
Update(ctx context.Context, user *User) error
Delete(ctx context.Context, id UserID) error
}
// internal/modules/user/application/service.go
package application
import (
"context"
"fmt"
"regexp"
"app/internal/modules/user/domain"
)
type Service struct {
repo domain.Repository
}
func NewService(repo domain.Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) CreateUser(ctx context.Context, email, name string) (*domain.User, error) {
if !isValidEmail(email) {
return nil, domain.ErrInvalidEmail
}
// Check if user exists
existing, _ := s.repo.GetByEmail(ctx, email)
if existing != nil {
return nil, domain.ErrUserAlreadyExists
}
user := &domain.User{
ID: domain.UserID(generateID()),
Email: email,
Name: name,
Active: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.repo.Create(ctx, user); err != nil {
return nil, fmt.Errorf("failed to create user: %w", err)
}
return user, nil
}
func (s *Service) GetUser(ctx context.Context, id domain.UserID) (*domain.User, error) {
return s.repo.GetByID(ctx, id)
}
func (s *Service) ValidateUser(ctx context.Context, id domain.UserID) (bool, error) {
user, err := s.repo.GetByID(ctx, id)
if err != nil {
return false, err
}
return user.Active, nil
}
func isValidEmail(email string) bool {
emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
return emailRegex.MatchString(email)
}
func generateID() string {
return fmt.Sprintf("user_%d", time.Now().UnixNano())
}
// internal/modules/user/infrastructure/postgres_repository.go
package infrastructure
import (
"context"
"database/sql"
"fmt"
"app/internal/modules/user/domain"
)
type PostgresRepository struct {
db *sql.DB
}
func NewPostgresRepository(db *sql.DB) *PostgresRepository {
return &PostgresRepository{db: db}
}
func (r *PostgresRepository) Create(ctx context.Context, user *domain.User) error {
query := `
INSERT INTO users.users (id, email, name, active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6)
`
_, err := r.db.ExecContext(ctx, query,
user.ID, user.Email, user.Name, user.Active, user.CreatedAt, user.UpdatedAt)
return err
}
func (r *PostgresRepository) GetByID(ctx context.Context, id domain.UserID) (*domain.User, error) {
query := `
SELECT id, email, name, active, created_at, updated_at
FROM users.users
WHERE id = $1
`
user := &domain.User{}
err := r.db.QueryRowContext(ctx, query, id).Scan(
&user.ID, &user.Email, &user.Name, &user.Active, &user.CreatedAt, &user.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, domain.ErrUserNotFound
}
return user, err
}
func (r *PostgresRepository) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
query := `
SELECT id, email, name, active, created_at, updated_at
FROM users.users
WHERE email = $1
`
user := &domain.User{}
err := r.db.QueryRowContext(ctx, query, email).Scan(
&user.ID, &user.Email, &user.Name, &user.Active, &user.CreatedAt, &user.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, domain.ErrUserNotFound
}
return user, err
}
func (r *PostgresRepository) Update(ctx context.Context, user *domain.User) error {
query := `
UPDATE users.users
SET email = $2, name = $3, active = $4, updated_at = $5
WHERE id = $1
`
_, err := r.db.ExecContext(ctx, query,
user.ID, user.Email, user.Name, user.Active, user.UpdatedAt)
return err
}
func (r *PostgresRepository) Delete(ctx context.Context, id domain.UserID) error {
query := `DELETE FROM users.users WHERE id = $1`
_, err := r.db.ExecContext(ctx, query, id)
return err
}
// internal/modules/user/module.go
package user
import (
"database/sql"
"app/internal/modules/user/application"
"app/internal/modules/user/infrastructure"
)
type Module struct {
Service *application.Service
}
func NewModule(db *sql.DB) *Module {
repo := infrastructure.NewPostgresRepository(db)
service := application.NewService(repo)
return &Module{
Service: service,
}
}
Module 2: Product Module
// internal/modules/product/domain/product.go
package domain
import (
"context"
"errors"
"time"
)
type ProductID string
type Product struct {
ID ProductID
Name string
Description string
Price float64
Stock int
CreatedAt time.Time
UpdatedAt time.Time
}
var (
ErrProductNotFound = errors.New("product not found")
ErrInsufficientStock = errors.New("insufficient stock")
ErrInvalidPrice = errors.New("invalid price")
)
type Repository interface {
Create(ctx context.Context, product *Product) error
GetByID(ctx context.Context, id ProductID) (*Product, error)
Update(ctx context.Context, product *Product) error
ReserveStock(ctx context.Context, id ProductID, quantity int) error
ReleaseStock(ctx context.Context, id ProductID, quantity int) error
}
// internal/modules/product/application/service.go
package application
import (
"context"
"fmt"
"app/internal/modules/product/domain"
)
type Service struct {
repo domain.Repository
}
func NewService(repo domain.Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) CreateProduct(ctx context.Context, name, description string, price float64, stock int) (*domain.Product, error) {
if price <= 0 {
return nil, domain.ErrInvalidPrice
}
product := &domain.Product{
ID: domain.ProductID(generateID()),
Name: name,
Description: description,
Price: price,
Stock: stock,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.repo.Create(ctx, product); err != nil {
return nil, fmt.Errorf("failed to create product: %w", err)
}
return product, nil
}
func (s *Service) GetProduct(ctx context.Context, id domain.ProductID) (*domain.Product, error) {
return s.repo.GetByID(ctx, id)
}
func (s *Service) CheckStock(ctx context.Context, id domain.ProductID, quantity int) (bool, error) {
product, err := s.repo.GetByID(ctx, id)
if err != nil {
return false, err
}
return product.Stock >= quantity, nil
}
func (s *Service) ReserveStock(ctx context.Context, id domain.ProductID, quantity int) error {
available, err := s.CheckStock(ctx, id, quantity)
if err != nil {
return err
}
if !available {
return domain.ErrInsufficientStock
}
return s.repo.ReserveStock(ctx, id, quantity)
}
func (s *Service) ConfirmReservation(ctx context.Context, id domain.ProductID, quantity int) error {
// In a real implementation, this would mark the reservation as confirmed
return nil
}
func generateID() string {
return fmt.Sprintf("product_%d", time.Now().UnixNano())
}
// internal/modules/product/infrastructure/postgres_repository.go
package infrastructure
import (
"context"
"database/sql"
"app/internal/modules/product/domain"
)
type PostgresRepository struct {
db *sql.DB
}
func NewPostgresRepository(db *sql.DB) *PostgresRepository {
return &PostgresRepository{db: db}
}
func (r *PostgresRepository) Create(ctx context.Context, product *domain.Product) error {
query := `
INSERT INTO products.products (id, name, description, price, stock, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`
_, err := r.db.ExecContext(ctx, query,
product.ID, product.Name, product.Description, product.Price,
product.Stock, product.CreatedAt, product.UpdatedAt)
return err
}
func (r *PostgresRepository) GetByID(ctx context.Context, id domain.ProductID) (*domain.Product, error) {
query := `
SELECT id, name, description, price, stock, created_at, updated_at
FROM products.products
WHERE id = $1
`
product := &domain.Product{}
err := r.db.QueryRowContext(ctx, query, id).Scan(
&product.ID, &product.Name, &product.Description, &product.Price,
&product.Stock, &product.CreatedAt, &product.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, domain.ErrProductNotFound
}
return product, err
}
func (r *PostgresRepository) Update(ctx context.Context, product *domain.Product) error {
query := `
UPDATE products.products
SET name = $2, description = $3, price = $4, stock = $5, updated_at = $6
WHERE id = $1
`
_, err := r.db.ExecContext(ctx, query,
product.ID, product.Name, product.Description, product.Price,
product.Stock, product.UpdatedAt)
return err
}
func (r *PostgresRepository) ReserveStock(ctx context.Context, id domain.ProductID, quantity int) error {
query := `
UPDATE products.products
SET stock = stock - $2
WHERE id = $1 AND stock >= $2
`
result, err := r.db.ExecContext(ctx, query, id, quantity)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return domain.ErrInsufficientStock
}
return nil
}
func (r *PostgresRepository) ReleaseStock(ctx context.Context, id domain.ProductID, quantity int) error {
query := `
UPDATE products.products
SET stock = stock + $2
WHERE id = $1
`
_, err := r.db.ExecContext(ctx, query, id, quantity)
return err
}
// internal/modules/product/module.go
package product
import (
"database/sql"
"app/internal/modules/product/application"
"app/internal/modules/product/infrastructure"
)
type Module struct {
Service *application.Service
}
func NewModule(db *sql.DB) *Module {
repo := infrastructure.NewPostgresRepository(db)
service := application.NewService(repo)
return &Module{
Service: service,
}
}
Module 3: Order Module (Coordinates Other Modules)
// internal/modules/order/domain/order.go
package domain
import (
"context"
"errors"
"time"
"app/internal/modules/user/domain"
"app/internal/modules/product/domain"
)
type OrderID string
type OrderStatus string
const (
OrderStatusPending OrderStatus = "pending"
OrderStatusConfirmed OrderStatus = "confirmed"
OrderStatusCancelled OrderStatus = "cancelled"
)
type OrderItem struct {
ProductID domain.ProductID
Quantity int
Price float64
}
type Order struct {
ID OrderID
UserID domain.UserID
Items []OrderItem
Total float64
Status OrderStatus
CreatedAt time.Time
UpdatedAt time.Time
}
var (
ErrOrderNotFound = errors.New("order not found")
ErrInvalidOrder = errors.New("invalid order")
)
type Repository interface {
Create(ctx context.Context, order *Order) error
GetByID(ctx context.Context, id OrderID) (*Order, error)
Update(ctx context.Context, order *Order) error
}
// internal/modules/order/application/service.go
package application
import (
"context"
"fmt"
"time"
orderdomain "app/internal/modules/order/domain"
productapp "app/internal/modules/product/application"
userapp "app/internal/modules/user/application"
)
// Service coordinates between modules
type Service struct {
repo orderdomain.Repository
userService *userapp.Service
productService *productapp.Service
}
func NewService(
repo orderdomain.Repository,
userService *userapp.Service,
productService *productapp.Service,
) *Service {
return &Service{
repo: repo,
userService: userService,
productService: productService,
}
}
func (s *Service) CreateOrder(ctx context.Context, userID domain.UserID, items []orderdomain.OrderItem) (*orderdomain.Order, error) {
// Validate user through User module
valid, err := s.userService.ValidateUser(ctx, userID)
if err != nil {
return nil, fmt.Errorf("failed to validate user: %w", err)
}
if !valid {
return nil, fmt.Errorf("user is not active")
}
// Calculate total and validate products
var total float64
for i, item := range items {
product, err := s.productService.GetProduct(ctx, item.ProductID)
if err != nil {
return nil, fmt.Errorf("failed to get product: %w", err)
}
// Check stock availability
available, err := s.productService.CheckStock(ctx, item.ProductID, item.Quantity)
if err != nil {
return nil, fmt.Errorf("failed to check stock: %w", err)
}
if !available {
return nil, fmt.Errorf("insufficient stock for product %s", item.ProductID)
}
items[i].Price = product.Price
total += product.Price * float64(item.Quantity)
}
// Reserve stock for all items
for _, item := range items {
if err := s.productService.ReserveStock(ctx, item.ProductID, item.Quantity); err != nil {
// Rollback reservations on failure
return nil, fmt.Errorf("failed to reserve stock: %w", err)
}
}
order := &orderdomain.Order{
ID: orderdomain.OrderID(generateID()),
UserID: userID,
Items: items,
Total: total,
Status: orderdomain.OrderStatusPending,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.repo.Create(ctx, order); err != nil {
return nil, fmt.Errorf("failed to create order: %w", err)
}
return order, nil
}
func (s *Service) GetOrder(ctx context.Context, id orderdomain.OrderID) (*orderdomain.Order, error) {
return s.repo.GetByID(ctx, id)
}
func (s *Service) ConfirmOrder(ctx context.Context, id orderdomain.OrderID) error {
order, err := s.repo.GetByID(ctx, id)
if err != nil {
return err
}
// Confirm stock reservations
for _, item := range order.Items {
if err := s.productService.ConfirmReservation(ctx, item.ProductID, item.Quantity); err != nil {
return fmt.Errorf("failed to confirm reservation: %w", err)
}
}
order.Status = orderdomain.OrderStatusConfirmed
order.UpdatedAt = time.Now()
return s.repo.Update(ctx, order)
}
func generateID() string {
return fmt.Sprintf("order_%d", time.Now().UnixNano())
}
// internal/modules/order/module.go
package order
import (
"database/sql"
"app/internal/modules/order/application"
"app/internal/modules/order/infrastructure"
productapp "app/internal/modules/product/application"
userapp "app/internal/modules/user/application"
)
type Module struct {
Service *application.Service
}
func NewModule(db *sql.DB, userService *userapp.Service, productService *productapp.Service) *Module {
repo := infrastructure.NewPostgresRepository(db)
service := application.NewService(repo, userService, productService)
return &Module{
Service: service,
}
}
Main Application
// cmd/app/main.go
package main
import (
"database/sql"
"log"
"net/http"
_ "github.com/lib/pq"
"app/internal/modules/user"
"app/internal/modules/product"
"app/internal/modules/order"
)
func main() {
// Initialize database
db, err := sql.Open("postgres", "postgres://user:pass@localhost/modular_monolith?sslmode=disable")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// Initialize modules
userModule := user.NewModule(db)
productModule := product.NewModule(db)
orderModule := order.NewModule(db, userModule.Service, productModule.Service)
// Setup HTTP routes
mux := http.NewServeMux()
// User endpoints
mux.HandleFunc("POST /users", func(w http.ResponseWriter, r *http.Request) {
// Handle user creation
})
// Product endpoints
mux.HandleFunc("POST /products", func(w http.ResponseWriter, r *http.Request) {
// Handle product creation
})
// Order endpoints
mux.HandleFunc("POST /orders", func(w http.ResponseWriter, r *http.Request) {
// Handle order creation using orderModule.Service
})
log.Println("Server starting on :8080")
if err := http.ListenAndServe(":8080", mux); err != nil {
log.Fatal(err)
}
}
Best Practices
- Module Boundaries: Keep modules independent with clear interfaces
- Shared Database: Use schemas or table prefixes to separate module data
- Module APIs: Define explicit APIs for inter-module communication
- Dependency Direction: Modules should depend on interfaces, not implementations
- Event-Driven Communication: Use events for async inter-module communication
- Transaction Management: Handle cross-module transactions carefully
- Testing: Test modules independently with mocked dependencies
- Documentation: Document module APIs and boundaries clearly
Common Pitfalls
- Shared Models: Sharing domain models between modules creates tight coupling
- Direct Database Access: Modules accessing other modules’ database tables
- Circular Dependencies: Modules depending on each other directly
- Anemic Modules: Modules with no business logic, just CRUD operations
- God Modules: Modules that know too much about other modules
- Ignoring Boundaries: Calling internal implementations instead of module APIs
- Synchronous Coupling: Over-reliance on synchronous inter-module calls
When to Use Modular Monolith
Use When:
- Starting a new project with potential for growth
- Want benefits of modularity without microservices complexity
- Team is not ready for distributed systems
- Need simpler deployment and operations
- Domain boundaries are clear but don’t justify microservices
- Want to delay microservices decision
Avoid When:
- Modules need independent scaling characteristics
- Different modules have different technology requirements
- Teams are distributed and need complete autonomy
- System already benefits from service mesh capabilities
- Regulatory requirements demand physical separation
Advantages
- Simpler Deployment: Single deployment unit, easier operations
- Better Performance: No network overhead for module communication
- Easier Debugging: All code in one process, simpler troubleshooting
- ACID Transactions: Easy to maintain consistency across modules
- Faster Development: No need for service orchestration
- Lower Costs: Fewer infrastructure requirements than microservices
- Migration Path: Easy to extract modules to microservices later
Disadvantages
- Shared Resources: All modules share same memory and CPU
- Coupled Deployment: Must deploy entire application for any change
- Technology Lock-in: All modules must use same programming language
- Scaling Limitations: Cannot scale individual modules independently
- Team Coordination: Teams must coordinate on deployments
- Blast Radius: Bug in one module can affect entire application
- Database Coupling: Harder to maintain module independence with shared DB
Modular Monolith Architecture provides an excellent middle ground between traditional monoliths and microservices, offering modularity and maintainability while keeping deployment and operations simple.
Go Architecture Patterns Series: ← Previous: Domain-Driven Design | Series Overview | Next: Microservices Architecture →