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(ctx context.Context, dashboardNameStr, investorIDStr, periodTypeStr, toolTemplateStr string) (dashboard *domain.Dashboard, err error) {
...
// 1. Business Logic & Entity Creation

dashboardName, err := domain.NewDashboardName(dashboardNameStr)
if err != nil {
return nil, fmt.Errorf("new dashboardName from %s: %w", dashboardNameStr, err)
}

investorID, err := domain.NewInvestorID(investorIDStr)
if err != nil {
return nil, fmt.Errorf("new investorID from %s: %w", investorIDStr, err)
}

dashboard, err := domain.NewDashboard(dashboardName, investorID)
if err != nil {
return fmt.Errorf("domain validation failed: %w", err)
}
...
// 2. Persist using the abstracted interface
if err := uc.repo.Save(ctx, dashboard); err != nil {
return fmt.Errorf("failed to save dashboard: %w", err)
}
return dashboard, 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(ctx context.Context, d *domain.Dashboard) error {
...
// 1. Map Domain Entity -> DB Schema Object (Isolation)
dbSchema := mapper.DomainToSchema(d)
...
// 2. Execute DB Transaction
tx := r.db.WithContext(ctx).Begin(&sql.TxOptions{
Isolation: sql.LevelReadCommitted,
ReadOnly: false,
})
if err != nil {
return err
}
defer tx.Rollback()

// 1. Save the Aggregate Root
err = r.saveDashboard(tx, d)
if err != nil {
return fmt.Errorf("save dashboard dID %s: %w", d.ID(), err)
}

// 2. Save Watchlists (Entities) in the Aggregate
err = r.saveWatchlists(tx, d.ID(), d.Watchlists())
if err != nil {
return fmt.Errorf("save watchlists in dID %s: %w", d.ID(), err)
}
...
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.