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