Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

8 Commits
 
 
 
 
 
 
 
 

Repository files navigation

I navigate the complexities of structuring large Go applications, because a well-designed modular monolith offers a compelling balance between development velocity and future scalability, often without the immediate overhead of microservices.

Core Principles

My experience suggests that the foundation of a robust Go modular monolith lies in a set of principles that lean into Go's strengths while addressing the inherent challenges of scale. I consistently find that these principles, when applied thoughtfully, prevent premature complexity and foster sustainable growth.

First, embrace Go's philosophy of explicit, minimal abstraction. Go is not a language where I expect to follow complex architectural patterns to a tee. Instead, it thrives when I adopt abstractions very sparingly (user comment, 5 upvotes, not independently verified) [1]. This means resisting the urge to introduce interfaces, layers, or generic solutions until a real, recurring problem necessitates it. My preference is always for clear, direct code over elegant-but-unnecessary indirection.

Second, prioritize domain modeling over technical layering. Many of the problems I encounter, such as "transaction management across modules" or "figuring out where code should live," often stem not from Go itself, but from insufficient time spent on proper domain exploration and bounded context design (user comment, 2 upvotes, not independently verified) [2]. Swapping Go for another language won't fix a fuzzy understanding of the business problem. I concentrate on identifying distinct business capabilities and their boundaries first.

Third, start simple and allow modularity to emerge organically. This is perhaps the most critical principle. I've learned the hard way that breaking things down into too many modules too soon often leads to a tangled mess where the initial architectural assumptions no longer align with the application's evolved needs (user comment, 16 upvotes, not independently verified) [3]. My approach is to "just write the code and see what makes sense to abstract or DRY" (user comment, not independently verified) [4]. A modular monolith should grow into its structure, not be forced into one from day one. I view modules as solutions to real problems, not as aesthetic choices (user comment, 3 upvotes, not independently verified) [5].

Fourth, recognize and strategically manage coupling. While "loose coupling" is a common mantra, some things should be coupled. If I need a set of changes to happen within a single transaction, those components are inherently coupled by that transactional boundary. Identifying these natural coupling points helps me design modules that truly cohere. I don't try to decouple things that logically belong together and change synchronously.

Do's and Don'ts with Examples

When building a Go modular monolith, specific patterns and anti-patterns emerge. I find the following guidelines particularly useful in practice.

Scenario: Imagine I'm maintaining an evolving e-commerce platform written in Go. Initially, it was a single, flat repository. As features like user authentication, product catalog management, order processing, and payment integration grew, the codebase became increasingly difficult to navigate. My team struggles with understanding ownership, preventing accidental side-effects, and efficiently onboarding new developers. This is where strategic modularity becomes crucial.

Do's

1. Do Couple Where Necessary (Transactional Boundaries) I look for areas where changes must happen together, often within a single transaction. These are prime candidates for components that can be tightly coupled within a single module or shared responsibility.

Example: In an e-commerce system, creating an order and deducting inventory are often atomic operations. I would keep these services within the same logical "Order" bounded context, even if they touch different data stores internally.

// internal/order/service.go
package order

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/google/uuid"
	"my-monolith/internal/inventory" // Accessing an internal inventory service
)

// Order represents an order in the system.
type Order struct {
	ID        string
	UserID    string
	SKU       string
	Quantity  int
	Price     float64
	Status    string
	CreatedAt time.Time
}

// OrderRepository defines operations for managing orders.
type OrderRepository interface {
	CreateOrder(ctx context.Context, order Order) error
	// ... other order operations
}

// InventoryService defines the interface for inventory operations.
// This would be defined in internal/inventory/service.go or internal/inventory/api.go
type InventoryService interface {
	DeductStock(ctx context.Context, sku string, quantity int) error
	RollbackStock(ctx context.Context, sku string, quantity int) error
}

// Service manages order-related business logic.
type Service struct {
	orderRepo    OrderRepository
	inventorySvc InventoryService
}

func NewService(or OrderRepository, is InventoryService) *Service {
	return &Service{
		orderRepo:    or,
		inventorySvc: is,
	}
}

// CreateNewOrder handles the business logic for creating a new order,
// including inventory deduction.
func (s *Service) CreateNewOrder(ctx context.Context, userID, sku string, quantity int, price float64) (Order, error) {
	if quantity <= 0 {
		return Order{}, errors.New("quantity must be positive")
	}

	// Step 1: Deduct stock from inventory
	err := s.inventorySvc.DeductStock(ctx, sku, quantity)
	if err != nil {
		return Order{}, fmt.Errorf("failed to deduct stock: %w", err)
	}

	// Step 2: Create the order
	newOrder := Order{
		ID:        uuid.New().String(),
		UserID:    userID,
		SKU:       sku,
		Quantity:  quantity,
		Price:     price,
		Status:    "pending",
		CreatedAt: time.Now(),
	}
	err = s.orderRepo.CreateOrder(ctx, newOrder)
	if err != nil {
		// If order creation fails, attempt to rollback stock.
		// In a real system, robust compensation or saga pattern might be needed.
		rollbackErr := s.inventorySvc.RollbackStock(ctx, sku, quantity)
		if rollbackErr != nil {
			// Log this severe error: inconsistent state!
			return Order{}, fmt.Errorf("failed to create order and failed to rollback stock for SKU %s: %w, original error: %v", sku, rollbackErr, err)
		}
		return Order{}, fmt.Errorf("failed to create order: %w", err)
	}

	return newOrder, nil
}

In this example, the order.Service directly uses an inventory.InventoryService interface. This ensures that the two operations are coordinated, even if the underlying InventoryService implementation is complex. The shared transaction or compensation logic keeps them cohesive.

2. Do Use internal Packages for Strong Module Boundaries The internal directory in Go is a powerful tool for enforcing architectural boundaries. I use it to house code that should only be imported by packages within the same module, preventing accidental or unintended cross-module dependencies.

Example: My modular monolith might have internal/user, internal/product, and internal/order modules. A package outside internal/user (e.g., internal/order) cannot directly import code from internal/user/repository.go. It must go through the public interface of the user module (e.g., internal/user/service.go or an exposed internal/user/userapi.go).

my-monolith/
├── cmd/
│   └── api/
│       └── main.go       # Orchestrates the various internal services
├── internal/
│   ├── user/             # User bounded context
│   │   ├── service.go    # Public interface for user operations
│   │   └── repository.go # User persistence (internal to 'user' module)
│   ├── order/            # Order bounded context
│   │   ├── service.go    # Public interface for order operations
│   │   └── repository.go # Order persistence (internal to 'order' module)
│   └── common/           # Common utilities used by multiple internal modules
│       └── logger.go
├── pkg/                  # Reusable components that *could* be external
│   └── errors/
│       └── errors.go
└── go.mod

Here, cmd/api/main.go can import internal/user and internal/order. internal/order can import internal/common/logger. However, internal/order/service.go cannot directly import internal/user/repository.go—it must interact with internal/user via its service.go or an explicit API interface. This simple rule dramatically improves modularity.

3. Do Invest in Domain Modeling and Bounded Contexts As highlighted by the community (user comment, 2 upvotes, not independently verified) [6], the biggest structural problems often point to a lack of proper domain understanding. I dedicate significant time to collaboratively mapping out the business domain, identifying its core concepts, their relationships, and where natural boundaries occur. This informs my module structure far more effectively than any technical pattern.

Don'ts

1. Don't Over-Modularize Prematurely This is a trap I've fallen into myself. Starting with too many modules, expecting them to evolve neatly, often leads to refactoring headaches. "My first thought was you broke things down into too many modules too soon" (user comment, 16 upvotes, not independently verified) [7]. I find it's easier to split a cohesive module later than to merge scattered logic.

Example: Initially, don't create separate internal/authentication, internal/authorization, internal/user-profile modules. Start with a single internal/user module that handles all user-related concerns. Only split it if internal/authentication needs to be used by many other services without the baggage of user profiles, or if the user module itself becomes overwhelmingly complex.

2. Don't Over-Abstract for Abstraction's Sake Go's strength is its simplicity and explicitness. Introducing interfaces for every single type, or creating complex layers (e.g., a "service layer" that just calls a "repository layer" with no added logic) without a clear benefit, adds cognitive load and boilerplate without delivering real value. "Go is a language in which you need to adopt abstractions very sparingly" (user comment, 5 upvotes, not independently verified) [8].

Example: If MyService only ever has one implementation, I usually don't define a MyService interface just because "it's good practice." I'll use the concrete type. An interface becomes valuable when there are multiple implementations (e.g., mock for testing, different database backends), or when I need to define a contract for dependency inversion across module boundaries.

3. Don't Introduce a Network Layer Between Internal Packages I've seen attempts to enforce module boundaries by making inter-module communication go over HTTP or gRPC within the same monolith process. This adds unnecessary overhead (serialization, deserialization, network latency, even if local), complexity, and points to a misunderstanding of what a modular monolith is. "how is adding a network layer in between packages will solve their code structuring" (user comment, not independently verified) [9]. Within a monolith, modules communicate via direct function calls.

Example: Instead of internal/order making an HTTP call to internal/inventory (even if it's localhost), internal/order/service.go should directly call inventory.InventoryService.DeductStock(). The dependency is injected as an interface, allowing for different implementations (including mocking for tests), but the call itself is a local function call.

// BAD EXAMPLE: unnecessary network call within a monolith
// internal/order/service.go (making an HTTP call to another internal module)
/*
func (s *Service) CreateNewOrder(...) {
    // ...
    resp, err := http.Post("http://localhost:8080/inventory/deduct", "application/json", body) // Don't do this!
    // ...
}
*/

This approach negates many of the benefits of a monolith (simpler transactions, lower latency, easier debugging).

Tradeoffs of Modularity Approaches

I've observed that architectural choices impact various aspects of development and operation. While I don't have controlled benchmarks for specific performance metrics across these styles (e.g., CPU, RAM usage for a given workload on a specific hardware configuration on my Intel i9-13900K, 64GB RAM, running Go 1.21.5), general industry experience provides a qualitative understanding of their tradeoffs.

Aspect Pure Monolith (Flat structure) Modular Monolith (Internal packages) Microservices (Separate services)
Initial Overhead [Good] Very low, quick start-up. [Good] Low, slightly more upfront design than pure monolith. [Bad] High, significant infrastructure and design work required.
Deployment [Good] Single unit, simple. [Good] Single unit, simple. [Bad] Independent, but complex orchestration.
Team Autonomy [Bad] Low, shared codebase often leads to contention. [Good] Moderate, clear module ownership, but shared deployment. [Good] High, dedicated teams own services end-to-end.
Transactionality [Good] Simple, ACID within a single process/database. [Good] Simple within module/shared database, can coordinate across. [Bad] Distributed, complex (sagas, 2PC) for cross-service consistency.
Code Cohesion [Neutral] Varies, can be low in large projects. [Good] High within modules, clear boundaries. [Good] High within services.
Refactoring [Good] Easier within the monolith (IDEs help). [Neutral] Easier within modules, harder across strict module boundaries. [Bad] Harder across service boundaries.
Performance [Good] Local calls, low latency. [Good] Local calls, low latency. [Bad] Network overhead, higher latency.
Scalability [Neutral] Vertical scaling first, then horizontal via replication of the whole monolith. [Neutral] Same as pure monolith, but clearer internal separation for future extraction. [Good] Horizontal scaling per service, fine-grained resource allocation.
Complexity Mgt. [Bad] High cognitive load as codebase grows. [Good] Manages complexity by compartmentalizing domain logic. [Neutral] Shifts complexity from codebase to operations/distribution.
Technology Agnosticism [Bad] Single language/framework. [Bad] Single language/framework. [Good] Polyglot capabilities for different services.

Common Mistakes and Fixes

Through numerous projects, I've identified recurring pitfalls in Go modular monoliths. Understanding these mistakes and their practical fixes has been invaluable.

1. Mistake: Premature Modularization and Over-Abstraction As discussed, the most common trap is creating module boundaries or abstracting interfaces before the problem space is fully understood. This often results in modules that don't align with business domains, or interfaces that restrict future changes rather than enabling them. It's often driven by an eagerness to apply "best practices" from other ecosystems.

  • Fix: Delay architectural decisions until absolutely necessary. I prefer to start with a relatively flat package structure, grouping related code by feature or domain, rather than by technical layers (e.g., user/service.go, user/repository.go, order/service.go). I extract modules only when a clear, distinct bounded context emerges, when a package becomes too large to manage, or when teams need stricter ownership boundaries. The community consensus is strong here: "just write the code and see what makes sense to abstract or DRY" (user comment, not independently verified) [10].

2. Mistake: Ignoring Domain Modeling and Bounded Contexts Treating a modular monolith purely as a technical exercise in directory organization, without deep consideration for the business domain, inevitably leads to "anemic" modules or modules that cut across logical boundaries. This results in unclear responsibilities and difficulties in managing cross-module transactions.

  • Fix: Invest heavily in domain exploration. Before writing much code, I engage with domain experts to understand the core business processes, entities, and events. I identify natural bounded contexts (e.g., "User Management" vs. "Order Fulfillment" vs. "Payment Processing"). These contexts become the guiding force for my module boundaries, ensuring they are aligned with the business rather than arbitrary technical concerns. "the problem is not Go, but that you are not spending enough time to properly do domain modeling/exploration and bounded context design" (user comment, 2 upvotes, not independently verified) [10].

3. Mistake: Leaky Abstractions and Indirect Dependencies When modules have implicit knowledge of each other's internal structures, or communicate through mechanisms that are not explicitly defined (e.g., direct database access from another module, or relying on global state), it creates "leaky" abstractions. This makes refactoring difficult and introduces hidden coupling.

  • Fix: Enforce explicit dependencies and use internal packages strictly. I define clear interfaces for communication between modules, and modules only expose what's absolutely necessary. I leverage Go's internal directory feature rigorously to prevent unintended imports from outside a module. For shared concerns like logging or configuration, I create specific internal/shared packages.

4. Mistake: Over-reliance on ORMs or Complex Frameworks for Persistence While not strictly a modular monolith issue, in Go, I often see developers struggling with monolithic ORMs or complex data access layers that abstract away too much, leading to performance issues or difficulty in fine-tuning queries. This can complicate the persistence layer within modules.

  • Fix: Prefer simpler data access patterns. Within a Go modular monolith, I lean towards simpler data access patterns. This often means using the database/sql package directly, perhaps with a lightweight query builder or a simple ORM like GORM for basic CRUD, but hand-writing more complex queries. Each module encapsulates its own data access logic.

Real-world Patterns

My preferred structure for a Go modular monolith often converges on a blend of the standard Go project layout and the principles of domain-driven design, utilizing internal packages to define clear boundaries. I aim for a structure that is easy to navigate for new developers while providing strong guarantees about module independence.

1. Standard Go Project Layout as a Foundation I typically start with a layout similar to what golang-standards/project-layout suggests [10], then adapt it for modularity.

my-monolith/
├── cmd/                          # Main applications (binaries)
│   └── server/                   # The monolithic API server
│       └── main.go
├── internal/                     # Private application and library code. Cannot be imported by projects outside the monolith.
│   ├── auth/                     # Bounded context for Authentication and Authorization
│   │   ├── domain/               # Core entities, value

---

## Sources

**Primary source:** [Go Modular Monolith Guide](https://reddit.com/r/golang/comments/1tftqpj/) (Reddit thread)

**Official documentation:**

- [Go Project Layout](https://github.com/golang-standards/project-layout)
- [Google Wire](https://github.com/google/wire)
- [Effective Go](https://go.dev/doc/effective_go)

**Note:** Inline references like [1], [2] link to the primary Reddit thread. Upvote counts reflect time of collection, not independent verification.

[1]: https://reddit.com/r/golang/comments/1tftqpj/
[2]: https://reddit.com/r/golang/comments/1tftqpj/
[3]: https://reddit.com/r/golang/comments/1tftqpj/
[4]: https://reddit.com/r/golang/comments/1tftqpj/
[5]: https://reddit.com/r/golang/comments/1tftqpj/
[6]: https://reddit.com/r/golang/comments/1tftqpj/
[7]: https://reddit.com/r/golang/comments/1tftqpj/
[8]: https://reddit.com/r/golang/comments/1tftqpj/
[9]: https://reddit.com/r/golang/comments/1tftqpj/
[10]: https://reddit.com/r/golang/comments/1tftqpj/

## License

MIT

About

Go modular monolith architecture guide: real patterns, pitfalls, and project structures from production Go codebases.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors