Skip to main content

Clean Architecture & Dependency Inversion

How LZStock decouples core financial business rules from gRPC delivery and PostgreSQL persistence using strict Go interfaces.

clean arch light clean arch dark

TL;DR
  • Strict Domain Isolation: Enforced Clean Architecture principles by isolating the core financial business logic from gRPC delivery and PostgreSQL persistence, treating external frameworks purely as pluggable infrastructure.
  • Dependency Inversion via Go Interfaces: Abstracted database operations using repository interfaces, ensuring the UseCase orchestrator never imports concrete SQL/GORM implementations, thus avoiding the "Big Ball of Mud" anti-pattern.
  • Embraced the "Mapping Tax": Protected domain models from being polluted by gorm or json tags by implementing explicit mapping layers (Protobuf ➡️ DTO ➡️ Domain ➡️ DB Schema), guaranteeing absolute framework agnosticism at the cost of acceptable boilerplate.

The Objective

Financial business rules are the most critical—and most stable—asset of this platform. The infrastructure around them (like migrating from REST to gRPC, or swapping PostgreSQL for a NoSQL database) is volatile.

The objective of this architecture is to guarantee 100% isolation of the Core Domain. By strictly enforcing Dependency Inversion, the business logic becomes completely agnostic to external frameworks, eliminating the "Big Ball of Mud" anti-pattern and enabling blazing-fast unit testing via mock injection.

The Mental Model & Codebase Anatomy

In a pure Clean Architecture, dependencies point strictly inward. The Delivery layer (Controller) and the Persistence layer (Repository) are both plugins to the core Domain.

The Data Flow:

Codebase Anatomy:

mods
└── bc1-indicator-insights
├── controllers
│ ├── dashboardCommand.go
│ ├── dashboardQuery.go
│ ├── dashboard_test.go
│ ├── index.go
│ └── index_test.go
├── domain
│ ├── DashboardAggregate.go
│ ├── DashboardAggregate_test.go
│ ├── DashboardID.go
│ ├── DashboardID_test.go
│ ├── DashboardName.go
│ ├── DashboardName_test.go
│ ├── DashboardStatus.go
│ ├── DashboardStatus_test.go
│ ├── DashboardRepository.go
│ ├── ...
├── dtos
│ └── Dashboard.go
├── mapper
│ └── ToPb.go
├── persistence
│ └── pg
│ ├── repo
│ └── schema
└── useCases
├── Dashboard.go
├── DashboardRead.go
├── DashboardRead_test.go
├── Dashboard_test.go
├── index.go
└── index_test.go

Core Implementation

Below is a curated vertical slice demonstrating how Dependency Injection (DI) powers this architecture. Notice the complete absence of concrete database structs in the UseCase.

The Contract (Domain Layer)

// mods/bc1-indicator-insights/domain/repository.go

// The Contract: UseCase depends on this, Repository implements this.
type DashboardRepository interface {
Save(ctx context.Context, d *Dashboard) error
FindByID(ctx context.Context, id uuid.UUID) (*Dashboard, error)
}

The Orchestrator (UseCase Layer)

// mods/bc1-indicator-insights/useCases/Dashboard.go

type dashboardUseCase struct {
repo domain.DashboardRepository
}

// Constructor for Dependency Injection
func NewDashboardUseCase(r domain.DashboardRepository) *dashboardUseCase {
return &dashboardUseCase{repo: r}
}

func (uc *dashboardUseCase) CreateDashboard(d *dto.CreateDashboard) error {
...
// 1. Business Logic & Entity Creation
dashboard, err := domain.NewDashboard(dto.DashboardName, dto.InvestorID)
if err != nil {
return fmt.Errorf("domain validation failed: %w", err)
}
...
// 2. Persist using the abstracted interface
if err := uc.repo.Save(dashboard); err != nil {
return fmt.Errorf("failed to save dashboard: %w", err)
}
return nil
}

The Plugin (Repository Layer)

// mods/bc1-indicator-insights/pg/dashboard_repo.go

type pgDashboardRepo struct {
db *gorm.DB
}

// Ensure at compile-time that pgDashboardRepo implements the interface
var _ domain.DashboardRepository = (*pgDashboardRepo)(nil)

func (r *pgDashboardRepo) Save(d *domain.Dashboard) error {
...
// 1. Map Domain Entity -> DB Schema Object (Isolation)
dbSchema := mapper.DomainToSchema(d)
...
// 2. Execute DB Transaction
err := r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(dbSchema).Error; err != nil {
return err
}
return nil
})
...
return err
}

Edge Cases & Trade-offs

  • The "Mapping Tax" (Boilerplate): The most painful trade-off of Clean Architecture is the constant need to map data across boundaries (gRPC Protobuf ➡️ DTO ➡️ Domain Entity ➡️ GORM Schema). For simple CRUD operations, this feels like massive over-engineering. However, this strict boundary prevents GORM tags (gorm:"primaryKey") or Protobuf JSON tags from polluting the core financial entities, ensuring the domain remains completely framework-agnostic.
  • Transaction Management (Unit of Work): Currently, database transactions are managed inside the Repository (tx := r.db.Transaction). This works well for single-aggregate operations. However, if a UseCase needs to orchestrate multiple repositories atomically (e.g., creating a user and their default dashboard), this pattern breaks down. A future evolution would be implementing the Unit of Work (UoW) pattern, allowing the UseCase to control the transaction boundary without coupling itself to *gorm.DB.

The Outcome

By enforcing strict Dependency Inversion, the entire financial domain can be exhaustively unit-tested in milliseconds using generated mock repositories (gomock), achieving high test coverage without ever spinning up a PostgreSQL Docker container.