| name | Clean/Hexagonal Architecture Refactoring | ||||
|---|---|---|---|---|---|
| about | Refactor codebase to follow Clean Architecture and best practices | ||||
| title | Refactor: Implement Clean/Hexagonal Architecture | ||||
| labels |
|
||||
| assignees |
Refactor the Nuxt Ads codebase to follow Clean Architecture (Hexagonal Architecture) principles, improving maintainability, testability, and separation of concerns. This refactoring addresses architectural issues identified in project-audit.md and implements industry best practices.
- Separation of Concerns - Clear boundaries between domain, application, infrastructure, and presentation layers
- Testability - Easier to write unit tests for business logic without UI dependencies
- Maintainability - Easier to understand, modify, and extend the codebase
- Flexibility - Easier to swap implementations (e.g., different API backends, storage mechanisms)
- Type Safety - Stronger TypeScript types with proper validation
Purpose: Contains business entities, value objects, and business rules. No dependencies on external frameworks or libraries.
Proposed Structure:
domain/
├── ads/
│ ├── types.ts # Ad domain models (discriminated unions)
│ ├── validators.ts # Ad validation logic
│ ├── parsers.ts # Parse API responses to domain models
│ └── policies.ts # Ad selection and display eligibility rules
└── shared/
├── result.ts # Result<T, E> type for error handling
└── errors.ts # Domain-specific error types
Key Types:
// domain/ads/types.ts
export type AdType = 'AmazonBanner' | 'GoogleAdSense' | 'ImageAd'
export interface BaseAd {
id: string
type: AdType
displayRatio: number
}
export interface AmazonAd extends BaseAd {
type: 'AmazonBanner'
height: number
width: number
href: string
imageUrl: string
imageAlt: string
imageDescription?: string
price?: number
discountAmount?: string
}
export interface GoogleAd extends BaseAd {
type: 'GoogleAdSense'
format: string
layoutKey: string
slot: number
}
export interface ImageAd extends BaseAd {
type: 'ImageAd'
height: number
width: number
href: string
imageUrl: string
imageAlt: string
}
export type Ad = AmazonAd | GoogleAd | ImageAd
// Discriminated union helper
export function isAmazonAd(ad: Ad): ad is AmazonAd {
return ad.type === 'AmazonBanner'
}Validators:
// domain/ads/validators.ts
import { Result } from '../shared/result'
import { ValidationError } from '../shared/errors'
export function validateAmazonAd(data: unknown): Result<AmazonAd, ValidationError> {
// Validation logic with proper type guards
}
export function validateGoogleAd(data: unknown): Result<GoogleAd, ValidationError> {
// Validation logic
}Purpose: Orchestrates domain logic and infrastructure. Contains application-specific business rules.
Proposed Structure:
application/
├── ports/
│ ├── IAdRepository.ts # Interface for ad data access
│ └── IConfigProvider.ts # Interface for configuration
└── use-cases/
├── FetchRandomAd.ts # Use case: Fetch and validate random ad
└── CalculateDimensions.ts # Use case: Calculate iframe dimensions
Example Use Case:
// application/use-cases/FetchRandomAd.ts
import type { Ad } from '~/domain/ads/types'
import type { IAdRepository } from '~/application/ports/IAdRepository'
import { Result } from '~/domain/shared/result'
export class FetchRandomAdUseCase {
constructor(private adRepository: IAdRepository) {}
async execute(filters?: AdFilters): Promise<Result<Ad, Error>> {
try {
const rawAd = await this.adRepository.fetchRandom(filters)
const validated = validateAd(rawAd)
return Result.ok(validated)
} catch (error) {
return Result.err(new AdFetchError(error))
}
}
}Purpose: Implements interfaces defined in the application layer. Handles external dependencies like APIs, storage, configuration.
Proposed Structure:
infrastructure/
├── api/
│ ├── LaravelAdRepository.ts # Implementation of IAdRepository
│ ├── httpClient.ts # Configured $fetch wrapper
│ └── mappers.ts # Map API responses to domain models
├── config/
│ ├── RuntimeConfigProvider.ts # Implementation of IConfigProvider
│ └── validation.ts # Validate runtime config
└── messaging/
└── IframeMessenger.ts # PostMessage communication
Repository Implementation:
// infrastructure/api/LaravelAdRepository.ts
import type { IAdRepository } from '~/application/ports/IAdRepository'
import { parseAdResponse } from './mappers'
export class LaravelAdRepository implements IAdRepository {
constructor(
private baseUrl: string,
private httpClient: typeof $fetch
) {}
async fetchRandom(filters?: AdFilters): Promise<Ad> {
const params = new URLSearchParams({ random: '1', ...filters })
const response = await this.httpClient(
`${this.baseUrl}/api/ads?${params}`
)
return parseAdResponse(response)
}
}Config Provider:
// infrastructure/config/RuntimeConfigProvider.ts
import type { IConfigProvider } from '~/application/ports/IConfigProvider'
export class NuxtConfigProvider implements IConfigProvider {
private config = useRuntimeConfig()
getAdsServerUrl(): string {
if (!this.config.public.adsServer) {
throw new ConfigurationError('NUXT_PUBLIC_ADS_SERVER is not set')
}
return this.config.public.adsServer
}
getAdClient(): string {
return this.config.public.adClient || ''
}
}Purpose: Vue components focused solely on rendering and user interaction. Delegates business logic to composables.
Proposed Structure:
components/
├── RandomAd.vue # Simplified - only rendering logic
├── Amazon/
│ └── Banner.vue # Display logic only
├── Google/
│ └── AdSense.vue # Display logic only
└── shared/
├── AdLoader.vue # Loading state component
└── AdError.vue # Error display component
composables/
├── useAdService.ts # Composable wrapping use cases
├── useAdDimensions.ts # Composable for dimension calculations
└── useUtility.ts # Keep existing utility
Simplified Component:
// components/RandomAd.vue
<script setup lang="ts">
import { useAdService } from '~/composables/useAdService'
const { query } = useRoute()
const { ad, loading, error, fetchAd } = useAdService()
onMounted(() => {
fetchAd(query)
})
</script>
<template>
<div class="text-center">
<AdLoader v-if="loading" />
<AdError v-else-if="error" :error="error" />
<GoogleAdSense
v-else-if="ad?.type === 'GoogleAdSense'"
:ad="ad"
/>
<AmazonBanner
v-else-if="ad?.type === 'AmazonBanner'"
:ad="ad"
/>
<ImageAdDisplay
v-else-if="ad?.type === 'ImageAd'"
:ad="ad"
/>
<button
v-if="query?.sb === '1'"
@click="fetchAd(query)"
>
Shuffle
</button>
</div>
</template>Composable:
// composables/useAdService.ts
import { FetchRandomAdUseCase } from '~/application/use-cases/FetchRandomAd'
import { LaravelAdRepository } from '~/infrastructure/api/LaravelAdRepository'
import { NuxtConfigProvider } from '~/infrastructure/config/RuntimeConfigProvider'
export function useAdService() {
const configProvider = new NuxtConfigProvider()
const repository = new LaravelAdRepository(
configProvider.getAdsServerUrl(),
$fetch
)
const useCase = new FetchRandomAdUseCase(repository)
const ad = ref<Ad | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
const fetchAd = async (filters?: Record<string, string>) => {
loading.value = true
error.value = null
const result = await useCase.execute(filters)
if (result.isOk()) {
ad.value = result.value
} else {
error.value = result.error
console.error('[useAdService] Failed to fetch ad:', result.error)
}
loading.value = false
}
return {
ad: readonly(ad),
loading: readonly(loading),
error: readonly(error),
fetchAd,
}
}- Create
domain/ads/types.tswith discriminated unions - Create
domain/ads/validators.tswith validation logic - Create
domain/shared/result.tsfor type-safe error handling - Create
domain/shared/errors.tsfor domain-specific errors - Write unit tests for validators (no UI dependencies)
- Create
application/ports/IAdRepository.tsinterface - Create
application/ports/IConfigProvider.tsinterface - Create
application/use-cases/FetchRandomAd.ts - Write unit tests for use cases (mock repositories)
- Create
infrastructure/api/LaravelAdRepository.ts - Create
infrastructure/api/mappers.tsfor API-to-domain mapping - Create
infrastructure/config/RuntimeConfigProvider.ts - Add proper error handling and retries
- Write integration tests (can mock API)
- Create
composables/useAdService.tswrapping use cases - Refactor
components/RandomAd.vueto use composable - Update child components to accept domain models
- Remove business logic from components
- Update existing component tests
- Implement origin-based postMessage filtering (not
*) - Add runtime config validation on startup
- Add query parameter allowlist
- Add response schema validation (e.g., zod or valibot)
- Add proper TypeScript strict mode checks
- Update README with new architecture diagram
- Document layer responsibilities
- Add JSDoc comments to public APIs
- Achieve 80%+ test coverage
- Create architecture decision records (ADRs)
- Separation of Concerns: Business logic isolated from UI (100% of domain logic in
domain/andapplication/) - Test Coverage: ≥80% code coverage with unit tests
- Type Safety: Zero
anytypes in production code - Cyclomatic Complexity: No function with complexity >10
- All existing features work identically (no user-facing changes)
- All existing tests pass
- No new console errors or warnings
- Performance remains the same or improves
- Easier to add new ad types (extend domain models)
- Easier to swap API backends (implement
IAdRepository) - Easier to test business logic (isolated from Nuxt/Vue)
- Clear directory structure with README in each layer
// Mixing UI and business logic
<script setup>
const fetchAd = async () => {
const response = await $fetch(url) // Infrastructure concern in UI
if (response.ad_type === 'Amazon') { // Business logic in UI
// Parse and validate
}
}
</script>// Separated concerns
<script setup>
const { ad, loading, fetchAd } = useAdService() // UI delegates to composable
onMounted(() => fetchAd())
</script>- Clean Architecture by Robert C. Martin
- Hexagonal Architecture
- Vue.js Enterprise Boilerplate
- Nuxt Layers Documentation
- Refactor incrementally, one layer at a time
- Keep existing code working during migration
- Use feature flags if needed for gradual rollout
- Maintain existing test suite while adding new tests
- Each phase is independently testable
- Can pause after any phase without breaking production
- Original code remains until replacement is proven
- Performance Impact: Will additional abstraction layers impact bundle size or runtime performance?
- Learning Curve: How do we onboard new contributors to this architecture?
- Over-Engineering: Is this appropriate for a relatively small codebase? (Currently ~500 lines)
- Nuxt Conventions: How do we balance Clean Architecture with Nuxt's opinionated structure?
Before closing this issue, verify:
- All layers (domain, application, infrastructure, presentation) are properly separated
- Business logic is testable without UI dependencies
- All existing functionality works identically
- Test coverage ≥80%
- No TypeScript
anytypes in production code - Documentation updated (README, architecture diagrams)
- Code review completed by at least 2 maintainers
- Performance benchmarks show no regression
- Security audit passed (no new vulnerabilities)
- Addresses architectural concerns in
project-audit.md - Related to testing improvements in
TESTING.md - Supports contribution guidelines in
CONTRIBUTING.md
Primary: @ijklim (repository owner)
Reviewers: TBD
Estimated Effort: 40-60 hours (8-12 days part-time)
Priority: Medium (Technical Debt - Not blocking features)
NOTE: This is a comprehensive refactoring plan. We can implement it in phases and pause/adjust based on team feedback and project needs.