Skip to content

ADR-08: Clean Architecture Pattern

Korean Version

DateAuthorRepos
2025-01-03@KubrickCodeweb

Context

Initial Architecture Challenges

The initial service-oriented structure presented several challenges as the codebase grew:

Domain-Infrastructure Coupling:

  • Business logic was intertwined with database queries and HTTP handling
  • Changes to infrastructure details (PostgreSQL, River queue) required modifications to service layer
  • HTTP status codes were directly returned from service layer, violating separation of concerns

Limited Testability:

  • Unit testing was difficult due to direct dependencies on concrete implementations
  • Integration tests were required even for simple business rule verification
  • Mock injection was not possible without significant refactoring

AI-Assisted Development Constraints:

  • Large service files exceeded AI context windows, reducing LLM coding effectiveness
  • Cross-cutting concerns made it difficult for AI tools to understand modification scope
  • No clear boundaries for AI agents to work within bounded contexts

Evolution Timeline

ChangeMotivation
Introduce Service layer + StrictServerInterfaceExtract business logic from handler
Decouple HTTP status codes from serviceService layer was returning HTTP codes
Introduce domain layer with errors + modelsCentralize domain definitions
Apply Clean Architecture domain layerentity/ + port/ separation
Apply Clean Architecture usecase layerFeature-specific use cases
Apply Clean Architecture adapter layerRepository, Queue, Client implementations
Apply Clean Architecture handler layerComplete handler -> usecase -> domain flow

Alignment with Worker Service

The Worker service already adopted a 6-layer Clean Architecture (Worker ADR-02). Adopting a similar structure for Web backend ensures:

  • Consistent mental model across repositories
  • Reusable patterns for team members
  • Shared testing strategies

Decision

Adopt a 5-layer Clean Architecture for the Web backend.

Layer Structure

LayerLocationResponsibility
Entitydomain/entity/Pure business models, value objects
Portdomain/port/Interface definitions (DIP contracts)
UseCaseusecase/Business logic, feature orchestration
Adapteradapter/External implementations (DB, API, Queue)
Handlerhandler/HTTP entry points, request/response handling

Dependency Direction

handler -> usecase -> domain <- adapter
                        ^
                (implements port)
  • Domain Layer has no external dependencies
  • UseCase Layer depends only on Domain interfaces
  • Adapter Layer implements Domain port interfaces
  • Handler Layer injects UseCases directly

Why 5 Layers Instead of 6?

Worker uses 6 layers including separate Application and Infrastructure layers. Web simplifies this:

Worker (6-Layer)Web (5-Layer)Rationale
Application(merged into Handler)Web has single entry point (HTTP)
Infrastructure(merged into Adapter)Simpler DI wiring in Web context

Web backend's simpler requirements (HTTP-only entry point, smaller team) don't warrant the additional Infrastructure/Application separation.

Options Considered

Option A: 5-Layer Clean Architecture (Selected)

How It Works:

  • Domain layer defines pure entities and port interfaces
  • UseCase layer orchestrates business logic using ports
  • Adapter layer implements ports with specific technologies
  • Handler layer maps HTTP requests to UseCases

Pros:

  • Testability: UseCase testable with simple mock ports
  • Maintainability: Clear boundaries reduce cognitive load
  • AI-Friendliness: Isolated files fit within LLM context windows
  • Flexibility: Technology changes isolated to adapter layer
  • Consistency: Aligns with Worker architecture pattern

Cons:

  • More files and packages than monolithic approach
  • Understanding dependency flow requires documentation
  • Overhead for simple CRUD operations

Option B: Traditional Layered Architecture

How It Works:

  • Handler -> Service -> Repository pattern
  • Service layer contains all business logic
  • Repository handles database access

Pros:

  • Simpler initial structure
  • Fewer indirections
  • Common pattern, widely understood

Cons:

  • Service files become bloated as features grow
  • Testing requires mocking concrete classes
  • HTTP concerns leak into service layer
  • Technology coupling in service layer

Option C: Hexagonal Architecture

How It Works:

  • Ports and Adapters pattern
  • Less prescriptive internal structure
  • Inbound/Outbound adapter distinction

Pros:

  • Flexible internal organization
  • Well-documented pattern
  • Clear boundary concept

Cons:

  • Less guidance on internal layer structure
  • "Application hexagon" remains undefined
  • Clean Architecture provides more actionable structure

Option D: Keep Service-Oriented Structure

How It Works:

  • Continue with Handler -> Service pattern
  • Gradual refactoring when needed

Evaluation:

  • HTTP status codes in service layer violates separation
  • Testing complexity increases over time
  • AI agents struggle with large service files

Implementation

Port Interface Pattern

Interfaces are defined in Domain layer, not alongside implementations:

modules/{module}/
├── domain/
│   ├── entity/        # Pure Go models
│   │   └── analysis.go
│   └── port/          # Interface definitions
│       └── repository.go
├── usecase/           # One file per feature
│   └── get_analysis.go
├── adapter/           # External implementations
│   ├── repository_postgres.go
│   └── mapper/
│       └── response.go
└── handler/
    └── http.go        # StrictServerInterface impl

Error Handling Pattern

Domain errors are mapped to HTTP status codes in Handler layer:

Domain ErrorHTTP StatusPurpose
ErrNotFound404Analysis not found
ErrAlreadyQueued409Duplicate request
ErrRateLimited429Rate limit exceeded
(unexpected)500Internal error

UseCase Pattern

Each use case is a focused struct with port dependencies:

go
type GetAnalysisUseCase struct {
    queue      port.QueueService
    repository port.Repository
}

type GetAnalysisInput struct {
    Owner string
    Repo  string
}

func (uc *GetAnalysisUseCase) Execute(ctx context.Context, input GetAnalysisInput) (*AnalyzeResult, error)

Import Rules (Enforced by depguard)

LayerAllowed Imports
domain/entityNo external dependencies
domain/portOnly entity
usecaseOnly domain (entity + port)
adapterdomain + external libraries
handlerusecase + adapter/mapper

Consequences

Positive

Testability:

  • Domain logic testable without any mocks
  • UseCase testable with simple port mocks
  • No database/queue required for business rule verification
  • 90%+ coverage achievable with unit tests

Maintainability:

  • Clear boundaries reduce cognitive load
  • Changes to one layer rarely affect others
  • Easier onboarding with well-defined responsibilities
  • Code navigation follows predictable patterns

AI-Assisted Development:

  • Each file is self-contained within LLM context windows
  • AI agents can understand and regenerate entire modules
  • Explicit interfaces reduce cross-file dependency scanning
  • Bounded contexts enable effective AI-based refactoring

Flexibility:

  • Database migration: only adapter layer changes
  • Queue system switch: only adapter layer changes
  • New use case: add usecase file, wire in handler

Negative

Initial Complexity:

  • More packages and files than service-oriented approach
  • Understanding dependency flow requires documentation
  • Mitigation: CLAUDE.md documents layer structure; depguard enforces rules

Indirection:

  • More layers between HTTP request and business logic
  • Debugging may require tracing through multiple packages
  • Mitigation: Structured logging with context; clear naming conventions

Overhead for Simple Operations:

  • Even simple CRUD requires full layer traversal
  • May feel excessive for straightforward features
  • Mitigation: Accept overhead as investment in long-term maintainability

Migration Considerations

Existing modules migrated incrementally:

  1. analyzer: First module migrated
  2. auth: Full migration with 5 port interfaces
  3. github: Service layer replaced with usecases
  4. user: Bookmark and history features restructured

Each migration followed the same pattern: domain/entity -> domain/port -> usecase -> adapter -> handler.

References

Open-source test coverage insights