API / plans /clean-architecture-migration.md
sshinmen's picture
Update HF Spaces deployment with latest changes
2e6b65c
|
Raw
History Blame Contribute Delete
21.9 kB

Clean Architecture Migration Plan

Current Architecture Issues

  1. Mixed Concerns: Handlers contain HTTP, business logic, and persistence code
  2. Tight Coupling: Direct dependency on Gin, PostgreSQL, external APIs
  3. No Clear Boundaries: Domain logic scattered across packages
  4. Testing Difficulty: Hard to unit test without full infrastructure

Target Clean Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                     External World                           β”‚
β”‚  (Gin, PostgreSQL, Redis, OAuth APIs, AI APIs)              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                       β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                  Interface Adapters                          β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚   HTTP API   β”‚  β”‚  Repository  β”‚  β”‚ External Service β”‚  β”‚
β”‚  β”‚   Handlers   β”‚  β”‚  Implement   β”‚  β”‚    Clients       β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                       β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                  Application Layer                           β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚                  Use Cases (Services)                   β”‚ β”‚
β”‚  β”‚  - Orchestrate domain objects                          β”‚ β”‚
β”‚  β”‚  - Define application workflows                        β”‚ β”‚
β”‚  β”‚  - Transaction boundaries                              β”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                       β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    Domain Layer                              β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚     Entities     β”‚  β”‚ Value Objectsβ”‚  β”‚ Domain Eventsβ”‚  β”‚
β”‚  β”‚  (Auth, Config)  β”‚  β”‚  (Token, ID) β”‚  β”‚ (AuthCreated)β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”β”‚
β”‚  β”‚  Domain Services β”‚  β”‚     Repository Interfaces        β”‚β”‚
β”‚  β”‚ (TokenValidator) β”‚  β”‚  (ports - no implementation)     β”‚β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Phase 1: Domain Layer Extraction (Week 1-2)

1.1 Domain Entities

File: internal/domain/entities/auth.go

package entities

import (
    "time"
)

// Auth represents an authenticated OAuth session
type Auth struct {
    ID           AuthID
    Provider     Provider
    Status       AuthStatus
    AccessToken  Token
    RefreshToken Token
    ExpiresAt    time.Time
    CreatedAt    time.Time
    UpdatedAt    time.Time
    Metadata     map[string]string
}

// AuthID is a value object
type AuthID string

func (id AuthID) String() string { return string(id) }

// Provider represents supported OAuth providers
type Provider string

const (
    ProviderGemini      Provider = "gemini"
    ProviderClaude      Provider = "claude"
    ProviderCodex       Provider = "codex"
    ProviderAntigravity Provider = "antigravity"
    ProviderQwen        Provider = "qwen"
)

// AuthStatus represents the authentication state
type AuthStatus int

const (
    AuthStatusUnknown AuthStatus = iota
    AuthStatusPending
    AuthStatusActive
    AuthStatusExpired
    AuthStatusRevoked
)

func (s AuthStatus) String() string {
    switch s {
    case AuthStatusPending:
        return "pending"
    case AuthStatusActive:
        return "active"
    case AuthStatusExpired:
        return "expired"
    case AuthStatusRevoked:
        return "revoked"
    default:
        return "unknown"
    }
}

// Domain methods
func (a *Auth) IsExpired() bool {
    return time.Now().After(a.ExpiresAt)
}

func (a *Auth) CanRefresh() bool {
    return a.Status == AuthStatusActive && a.RefreshToken != ""
}

func (a *Auth) Refresh(newToken Token, newExpiry time.Time) error {
    if !a.CanRefresh() {
        return domainerrors.New("auth cannot be refreshed")
    }
    a.AccessToken = newToken
    a.ExpiresAt = newExpiry
    a.UpdatedAt = time.Now()
    return nil
}

func (a *Auth) Revoke() {
    a.Status = AuthStatusRevoked
    a.AccessToken = ""
    a.RefreshToken = ""
    a.UpdatedAt = time.Now()
}

1.2 Repository Ports

File: internal/domain/ports/repositories.go

package ports

import (
    "context"

    "github.com/echyai/cliproxyapi/internal/domain/entities"
)

// AuthRepository defines the interface for auth persistence
type AuthRepository interface {
    FindByID(ctx context.Context, id entities.AuthID) (*entities.Auth, error)
    FindByProvider(ctx context.Context, provider entities.Provider) ([]*entities.Auth, error)
    Save(ctx context.Context, auth *entities.Auth) error
    Delete(ctx context.Context, id entities.AuthID) error
    List(ctx context.Context, filter AuthFilter) ([]*entities.Auth, error)
}

// AuthFilter for querying auths
type AuthFilter struct {
    Provider   *entities.Provider
    Status     *entities.AuthStatus
    CreatedAfter *time.Time
}

// ConfigRepository defines the interface for config persistence
type ConfigRepository interface {
    Get(ctx context.Context) (*entities.Config, error)
    Save(ctx context.Context, cfg *entities.Config) error
    Watch(ctx context.Context) (<-chan entities.Config, error)
}

// TokenStore defines the interface for token storage
type TokenStore interface {
    Get(ctx context.Context, key string) (string, error)
    Set(ctx context.Context, key string, value string, ttl time.Duration) error
    Delete(ctx context.Context, key string) error
}

// UnitOfWork manages transactional operations
type UnitOfWork interface {
    Begin(ctx context.Context) (Context, error)
    Commit(ctx context.Context) error
    Rollback(ctx context.Context) error
}

// Context carries transaction context
type Context struct {
    context.Context
    Tx interface{} // Driver-specific transaction handle
}

1.3 Domain Services

File: internal/domain/services/token_validator.go

package services

import (
    "context"
    "time"

    "github.com/echyai/cliproxyapi/internal/domain/entities"
    "github.com/echyai/cliproxyapi/internal/domain/ports"
)

// TokenValidator handles token validation and refresh logic
type TokenValidator struct {
    authRepo     ports.AuthRepository
    providerRepo ports.ProviderRepository
    tokenStore   ports.TokenStore
}

func NewTokenValidator(
    authRepo ports.AuthRepository,
    providerRepo ports.ProviderRepository,
    tokenStore ports.TokenStore,
) *TokenValidator {
    return &TokenValidator{
        authRepo:     authRepo,
        providerRepo: providerRepo,
        tokenStore:   tokenStore,
    }
}

// ValidateAndRefresh checks token validity and refreshes if needed
func (v *TokenValidator) ValidateAndRefresh(
    ctx context.Context,
    authID entities.AuthID,
) (*entities.Auth, error) {
    auth, err := v.authRepo.FindByID(ctx, authID)
    if err != nil {
        return nil, err
    }

    if auth == nil {
        return nil, domainerrors.NewNotFound("auth", authID.String())
    }

    // Check if token needs refresh
    if !auth.IsExpired() {
        return auth, nil
    }

    if !auth.CanRefresh() {
        return nil, domainerrors.NewUnauthorized("token expired and cannot be refreshed")
    }

    // Get provider for refresh
    provider, err := v.providerRepo.GetOAuthProvider(ctx, auth.Provider)
    if err != nil {
        return nil, err
    }

    // Perform refresh
    newToken, newExpiry, err := provider.RefreshToken(ctx, auth.RefreshToken)
    if err != nil {
        return nil, domainerrors.NewProviderError(auth.Provider, err)
    }

    // Update auth entity
    if err := auth.Refresh(newToken, newExpiry); err != nil {
        return nil, err
    }

    // Persist changes
    if err := v.authRepo.Save(ctx, auth); err != nil {
        return nil, err
    }

    return auth, nil
}

Phase 2: Application Layer (Week 2-3)

2.1 Use Cases

File: internal/application/usecase/auth_usecase.go

package usecase

import (
    "context"

    "github.com/echyai/cliproxyapi/internal/application/dto"
    "github.com/echyai/cliproxyapi/internal/application/mapper"
    "github.com/echyai/cliproxyapi/internal/domain/entities"
    "github.com/echyai/cliproxyapi/internal/domain/ports"
    "github.com/echyai/cliproxyapi/internal/domain/services"
)

// AuthUseCase orchestrates auth-related operations
type AuthUseCase struct {
    authRepo      ports.AuthRepository
    providerRepo  ports.ProviderRepository
    tokenStore    ports.TokenStore
    validator     *services.TokenValidator
    mapper        *mapper.AuthMapper
    eventBus      ports.EventBus
}

func NewAuthUseCase(
    authRepo ports.AuthRepository,
    providerRepo ports.ProviderRepository,
    tokenStore ports.TokenStore,
    eventBus ports.EventBus,
) *AuthUseCase {
    return &AuthUseCase{
        authRepo:     authRepo,
        providerRepo: providerRepo,
        tokenStore:   tokenStore,
        validator:    services.NewTokenValidator(authRepo, providerRepo, tokenStore),
        mapper:       mapper.NewAuthMapper(),
        eventBus:     eventBus,
    }
}

// GetAuth retrieves an auth by ID with token validation
func (uc *AuthUseCase) GetAuth(ctx context.Context, id string) (*dto.AuthDTO, error) {
    authID := entities.AuthID(id)

    auth, err := uc.validator.ValidateAndRefresh(ctx, authID)
    if err != nil {
        return nil, err
    }

    return uc.mapper.ToDTO(auth), nil
}

// CreateAuth initiates OAuth flow
func (uc *AuthUseCase) CreateAuth(
    ctx context.Context,
    input dto.CreateAuthInput,
) (*dto.AuthDTO, error) {
    // Validate input
    if err := input.Validate(); err != nil {
        return nil, err
    }

    // Get OAuth provider
    provider, err := uc.providerRepo.GetOAuthProvider(ctx, entities.Provider(input.Provider))
    if err != nil {
        return nil, err
    }

    // Generate OAuth URL
    authURL, state, err := provider.GenerateAuthURL(ctx, input.RedirectURI)
    if err != nil {
        return nil, err
    }

    // Store pending state
    pendingAuth := entities.NewPendingAuth(
        entities.Provider(input.Provider),
        state,
        input.RedirectURI,
    )

    if err := uc.authRepo.Save(ctx, pendingAuth); err != nil {
        return nil, err
    }

    // Publish event
    uc.eventBus.Publish(ctx, events.AuthInitiated{
        AuthID:   pendingAuth.ID,
        Provider: pendingAuth.Provider,
        AuthURL:  authURL,
    })

    return uc.mapper.ToDTO(pendingAuth), nil
}

// CompleteAuth finishes OAuth flow
func (uc *AuthUseCase) CompleteAuth(
    ctx context.Context,
    input dto.CompleteAuthInput,
) (*dto.AuthDTO, error) {
    // Find pending auth by state
    auth, err := uc.authRepo.FindByState(ctx, input.State)
    if err != nil {
        return nil, err
    }

    if auth == nil {
        return nil, domainerrors.NewInvalidState("invalid or expired state")
    }

    // Exchange code for tokens
    provider, err := uc.providerRepo.GetOAuthProvider(ctx, auth.Provider)
    if err != nil {
        return nil, err
    }

    tokens, err := provider.ExchangeCode(ctx, input.Code)
    if err != nil {
        return nil, domainerrors.NewProviderError(auth.Provider, err)
    }

    // Activate auth
    auth.Activate(tokens.AccessToken, tokens.RefreshToken, tokens.ExpiresAt)

    if err := uc.authRepo.Save(ctx, auth); err != nil {
        return nil, err
    }

    // Publish event
    uc.eventBus.Publish(ctx, events.AuthCompleted{
        AuthID:   auth.ID,
        Provider: auth.Provider,
    })

    return uc.mapper.ToDTO(auth), nil
}

// ListAuths retrieves auths with filtering
func (uc *AuthUseCase) ListAuths(
    ctx context.Context,
    filter dto.AuthFilter,
) ([]*dto.AuthDTO, error) {
    domainFilter := ports.AuthFilter{}
    if filter.Provider != "" {
        p := entities.Provider(filter.Provider)
        domainFilter.Provider = &p
    }

    auths, err := uc.authRepo.List(ctx, domainFilter)
    if err != nil {
        return nil, err
    }

    return uc.mapper.ToDTOList(auths), nil
}

// DeleteAuth revokes and removes an auth
func (uc *AuthUseCase) DeleteAuth(ctx context.Context, id string) error {
    authID := entities.AuthID(id)

    auth, err := uc.authRepo.FindByID(ctx, authID)
    if err != nil {
        return err
    }

    if auth == nil {
        return domainerrors.NewNotFound("auth", id)
    }

    // Revoke with provider if active
    if auth.Status == entities.AuthStatusActive {
        provider, err := uc.providerRepo.GetOAuthProvider(ctx, auth.Provider)
        if err != nil {
            return err
        }

        if err := provider.RevokeToken(ctx, auth.AccessToken); err != nil {
            // Log but continue
        }
    }

    auth.Revoke()

    if err := uc.authRepo.Save(ctx, auth); err != nil {
        return err
    }

    uc.eventBus.Publish(ctx, events.AuthRevoked{
        AuthID:   auth.ID,
        Provider: auth.Provider,
    })

    return nil
}

2.2 DTOs

File: internal/application/dto/auth_dto.go

package dto

import (
    "time"
)

// AuthDTO represents auth data for API responses
type AuthDTO struct {
    ID        string            `json:"id"`
    Provider  string            `json:"provider"`
    Status    string            `json:"status"`
    ExpiresAt *time.Time        `json:"expires_at,omitempty"`
    CreatedAt time.Time         `json:"created_at"`
    Metadata  map[string]string `json:"metadata,omitempty"`
}

// CreateAuthInput for creating new auth
type CreateAuthInput struct {
    Provider    string `json:"provider" validate:"required,oneof=gemini claude codex antigravity qwen"`
    RedirectURI string `json:"redirect_uri" validate:"required,url"`
}

func (i CreateAuthInput) Validate() error {
    return validator.New().Struct(i)
}

// CompleteAuthInput for completing OAuth
type CompleteAuthInput struct {
    Code  string `json:"code" validate:"required"`
    State string `json:"state" validate:"required"`
}

func (i CompleteAuthInput) Validate() error {
    return validator.New().Struct(i)
}

// AuthFilter for listing auths
type AuthFilter struct {
    Provider string `json:"provider,omitempty"`
    Status   string `json:"status,omitempty"`
}

2.3 Mappers

File: internal/application/mapper/auth_mapper.go

package mapper

type AuthMapper struct{}

func NewAuthMapper() *AuthMapper {
    return &AuthMapper{}
}

func (m *AuthMapper) ToDTO(auth *entities.Auth) *dto.AuthDTO {
    if auth == nil {
        return nil
    }

    return &dto.AuthDTO{
        ID:        auth.ID.String(),
        Provider:  string(auth.Provider),
        Status:    auth.Status.String(),
        ExpiresAt: nullableTime(auth.ExpiresAt),
        CreatedAt: auth.CreatedAt,
        Metadata:  auth.Metadata,
    }
}

func (m *AuthMapper) ToDTOList(auths []*entities.Auth) []*dto.AuthDTO {
    result := make([]*dto.AuthDTO, len(auths))
    for i, auth := range auths {
        result[i] = m.ToDTO(auth)
    }
    return result
}

func nullableTime(t time.Time) *time.Time {
    if t.IsZero() {
        return nil
    }
    return &t
}

Phase 3: Infrastructure Layer (Week 3-4)

3.1 PostgreSQL Repository

File: internal/infrastructure/persistence/postgres/auth_repository.go

package postgres

import (
    "context"
    "database/sql"
    "errors"

    "github.com/jackc/pgx/v5"
    "github.com/jackc/pgx/v5/pgxpool"

    "github.com/echyai/cliproxyapi/internal/domain/entities"
    "github.com/echyai/cliproxyapi/internal/domain/ports"
)

type AuthRepository struct {
    db *pgxpool.Pool
}

func NewAuthRepository(db *pgxpool.Pool) *AuthRepository {
    return &AuthRepository{db: db}
}

func (r *AuthRepository) FindByID(ctx context.Context, id entities.AuthID) (*entities.Auth, error) {
    query := `
        SELECT id, provider, status, access_token, refresh_token,
               expires_at, created_at, updated_at, metadata
        FROM auths
        WHERE id = $1
    `

    var auth entities.Auth
    var metadata []byte

    err := r.db.QueryRow(ctx, query, id).Scan(
        &auth.ID,
        &auth.Provider,
        &auth.Status,
        &auth.AccessToken,
        &auth.RefreshToken,
        &auth.ExpiresAt,
        &auth.CreatedAt,
        &auth.UpdatedAt,
        &metadata,
    )

    if errors.Is(err, pgx.ErrNoRows) {
        return nil, nil
    }
    if err != nil {
        return nil, err
    }

    // Unmarshal metadata JSON
    if len(metadata) > 0 {
        json.Unmarshal(metadata, &auth.Metadata)
    }

    return &auth, nil
}

func (r *AuthRepository) Save(ctx context.Context, auth *entities.Auth) error {
    query := `
        INSERT INTO auths (id, provider, status, access_token, refresh_token,
                           expires_at, created_at, updated_at, metadata)
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
        ON CONFLICT (id) DO UPDATE SET
            status = EXCLUDED.status,
            access_token = EXCLUDED.access_token,
            refresh_token = EXCLUDED.refresh_token,
            expires_at = EXCLUDED.expires_at,
            updated_at = EXCLUDED.updated_at,
            metadata = EXCLUDED.metadata
    `

    metadata, _ := json.Marshal(auth.Metadata)

    _, err := r.db.Exec(ctx, query,
        auth.ID,
        auth.Provider,
        auth.Status,
        auth.AccessToken,
        auth.RefreshToken,
        auth.ExpiresAt,
        auth.CreatedAt,
        auth.UpdatedAt,
        metadata,
    )

    return err
}

// ... other methods

Phase 4: Interface Adapters (Week 4-5)

4.1 HTTP Handler (Thin)

File: internal/api/handlers/auth_handler.go

package handlers

import (
    "net/http"

    "github.com/gin-gonic/gin"

    "github.com/echyai/cliproxyapi/internal/application/dto"
    "github.com/echyai/cliproxyapi/internal/application/usecase"
)

type AuthHandler struct {
    usecase *usecase.AuthUseCase
}

func NewAuthHandler(uc *usecase.AuthUseCase) *AuthHandler {
    return &AuthHandler{usecase: uc}
}

func (h *AuthHandler) Register(r *gin.RouterGroup) {
    g := r.Group("/auths")
    {
        g.GET("", h.List)
        g.GET("/:id", h.Get)
        g.POST("", h.Create)
        g.POST("/callback", h.Complete)
        g.DELETE("/:id", h.Delete)
    }
}

func (h *AuthHandler) Get(c *gin.Context) {
    id := c.Param("id")

    auth, err := h.usecase.GetAuth(c.Request.Context(), id)
    if err != nil {
        _ = c.Error(err)
        return
    }

    c.JSON(http.StatusOK, Response{Data: auth})
}

func (h *AuthHandler) List(c *gin.Context) {
    filter := dto.AuthFilter{
        Provider: c.Query("provider"),
        Status:   c.Query("status"),
    }

    auths, err := h.usecase.ListAuths(c.Request.Context(), filter)
    if err != nil {
        _ = c.Error(err)
        return
    }

    c.JSON(http.StatusOK, Response{Data: auths})
}

func (h *AuthHandler) Create(c *gin.Context) {
    var input dto.CreateAuthInput
    if err := c.ShouldBindJSON(&input); err != nil {
        _ = c.Error(domainerrors.NewValidationError(err.Error()))
        return
    }

    auth, err := h.usecase.CreateAuth(c.Request.Context(), input)
    if err != nil {
        _ = c.Error(err)
        return
    }

    c.JSON(http.StatusCreated, Response{Data: auth})
}

func (h *AuthHandler) Complete(c *gin.Context) {
    var input dto.CompleteAuthInput
    if err := c.ShouldBindJSON(&input); err != nil {
        _ = c.Error(domainerrors.NewValidationError(err.Error()))
        return
    }

    auth, err := h.usecase.CompleteAuth(c.Request.Context(), input)
    if err != nil {
        _ = c.Error(err)
        return
    }

    c.JSON(http.StatusOK, Response{Data: auth})
}

func (h *AuthHandler) Delete(c *gin.Context) {
    id := c.Param("id")

    if err := h.usecase.DeleteAuth(c.Request.Context(), id); err != nil {
        _ = c.Error(err)
        return
    }

    c.Status(http.StatusNoContent)
}

4.2 Dependency Injection Wire

File: internal/wire/wire.go

//go:build wireinject
// +build wireinject

package wire

import (
    "github.com/google/wire"

    "github.com/echyai/cliproxyapi/internal/api/handlers"
    "github.com/echyai/cliproxyapi/internal/application/usecase"
    "github.com/echyai/cliproxyapi/internal/infrastructure/persistence/postgres"
)

func InitializeAuthHandler(db *pgxpool.Pool) *handlers.AuthHandler {
    wire.Build(
        postgres.NewAuthRepository,
        postgres.NewProviderRepository,
        postgres.NewTokenStore,
        usecase.NewAuthUseCase,
        handlers.NewAuthHandler,
    )
    return nil
}

Migration Strategy

  1. Week 1-2: Create domain layer (entities, ports)
  2. Week 2-3: Build application layer (use cases, DTOs)
  3. Week 3-4: Implement infrastructure (repositories)
  4. Week 4-5: Create thin HTTP handlers
  5. Week 6: Replace old handlers gradually
  6. Week 7: Remove old code

Success Criteria

  • Domain logic has no external dependencies
  • Handlers are < 50 lines each
  • 100% unit test coverage for use cases
  • Easy to swap PostgreSQL with other storage
  • Clear dependency direction: Handler -> UseCase -> Domain