# 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` ```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` ```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` ```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` ```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` ```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` ```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` ```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` ```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 //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