Skip to main content

Clean Architecture in Viax

Viax implements Clean Architecture as proposed by Robert C. Martin (Uncle Bob), separating code into distinct layers with well-defined responsibilities.

What is Clean Architecture?

Clean Architecture is a software design philosophy that enforces separation of concerns through layered architecture, making code:

Maintainable

Organized code that’s easy to understand and modify

Testable

Each layer can be tested independently

Scalable

Easy to add features without breaking existing code

Framework Independent

Business logic doesn’t depend on Flutter or any framework

The Dependency Rule

Critical Principle: Source code dependencies must point INWARD. Inner layers know nothing about outer layers.

Layer Architecture

Viax organizes each feature into three distinct layers:

Domain Layer (Core Business Logic)

Location: lib/src/features/{feature}/domain/
The domain layer contains pure business logic with ZERO external dependencies.

Components

Entities are business objects that encapsulate enterprise business rules.
// lib/src/features/conductor/domain/entities/conductor_profile.dart
class ConductorProfile {
  final int id;
  final int conductorId;
  final String nombreCompleto;
  final String telefono;
  final String? direccion;
  final DriverLicense? license;
  final Vehicle? vehicle;
  final String estadoVerificacion;
  
  const ConductorProfile({
    required this.id,
    required this.conductorId,
    required this.nombreCompleto,
    required this.telefono,
    this.direccion,
    this.license,
    this.vehicle,
    required this.estadoVerificacion,
  });
  
  // Business logic: Calculate profile completion
  int get completionPercentage {
    int completed = 0;
    int total = 5;
    
    if (nombreCompleto.isNotEmpty) completed++;
    if (telefono.isNotEmpty) completed++;
    if (direccion != null && direccion!.isNotEmpty) completed++;
    if (license != null) completed++;
    if (vehicle != null) completed++;
    
    return ((completed / total) * 100).round();
  }
  
  // Business logic: Check if ready for approval
  bool get canSubmitForApproval {
    return completionPercentage == 100 && 
           estadoVerificacion == 'pendiente';
  }
  
  // Immutability: Create modified copies
  ConductorProfile copyWith({
    String? nombreCompleto,
    String? telefono,
    String? direccion,
    DriverLicense? license,
    Vehicle? vehicle,
  }) {
    return ConductorProfile(
      id: id,
      conductorId: conductorId,
      nombreCompleto: nombreCompleto ?? this.nombreCompleto,
      telefono: telefono ?? this.telefono,
      direccion: direccion ?? this.direccion,
      license: license ?? this.license,
      vehicle: vehicle ?? this.vehicle,
      estadoVerificacion: estadoVerificacion,
    );
  }
}
Key Points:
  • Immutable objects (all fields final)
  • Business logic methods (completionPercentage, canSubmitForApproval)
  • No dependencies on UI or data layers
  • Pure Dart code
Benefits of Domain Layer:
  • ✅ 100% testable without mocks
  • ✅ Reusable across platforms (web, mobile, desktop)
  • ✅ Independent of frameworks
  • ✅ Pure business logic

Data Layer (Implementation)

Location: lib/src/features/{feature}/data/
The data layer implements repository contracts and handles all external data sources.

Components

Data Sources handle actual communication with external services.
// Interface
// lib/src/features/conductor/data/datasources/conductor_remote_datasource.dart
abstract class ConductorRemoteDataSource {
  Future<Map<String, dynamic>> getProfile(int conductorId);
  Future<Map<String, dynamic>> updateProfile(Map<String, dynamic> data);
}

// Implementation
// lib/src/features/conductor/data/datasources/conductor_remote_datasource_impl.dart
import 'package:http/http.dart' as http;
import 'dart:convert';

class ConductorRemoteDataSourceImpl implements ConductorRemoteDataSource {
  final http.Client client;
  final String baseUrl;
  
  ConductorRemoteDataSourceImpl({
    required this.client,
    required this.baseUrl,
  });
  
  @override
  Future<Map<String, dynamic>> getProfile(int conductorId) async {
    final response = await client.get(
      Uri.parse('$baseUrl/conductor/get_profile.php?conductor_id=$conductorId'),
      headers: {'Content-Type': 'application/json'},
    ).timeout(Duration(seconds: 30));
    
    if (response.statusCode == 200) {
      final json = jsonDecode(response.body);
      if (json['success'] == true) {
        return json['profile'];
      } else {
        throw ServerException(json['message'] ?? 'Unknown error');
      }
    } else {
      throw ServerException('HTTP ${response.statusCode}');
    }
  }
  
  @override
  Future<Map<String, dynamic>> updateProfile(Map<String, dynamic> data) async {
    final response = await client.post(
      Uri.parse('$baseUrl/conductor/update_profile.php'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode(data),
    );
    
    if (response.statusCode == 200) {
      final json = jsonDecode(response.body);
      if (json['success'] == true) {
        return json['profile'];
      } else {
        throw ServerException(json['message'] ?? 'Update failed');
      }
    } else {
      throw ServerException('HTTP ${response.statusCode}');
    }
  }
}
Key Points:
  • Concrete implementations
  • HTTP calls, database queries, etc.
  • Throws technical exceptions
  • Returns raw data (Maps, Lists)
Benefits of Data Layer:
  • ✅ Swappable data sources (API → Local DB)
  • ✅ Centralized error handling
  • ✅ Easy to mock for testing
  • ✅ Isolates technical details

Presentation Layer (UI & State)

Location: lib/src/features/{feature}/presentation/
The presentation layer handles UI rendering and state management.

Components

Providers manage UI state and invoke use cases.
// lib/src/features/conductor/presentation/providers/conductor_profile_provider.dart
import 'package:flutter/foundation.dart';
import '../../domain/entities/conductor_profile.dart';
import '../../domain/usecases/get_conductor_profile.dart';
import '../../domain/usecases/update_conductor_profile.dart';

class ConductorProfileProvider with ChangeNotifier {
  // Use cases (injected)
  final GetConductorProfile getConductorProfileUseCase;
  final UpdateConductorProfile updateConductorProfileUseCase;
  
  ConductorProfileProvider({
    required this.getConductorProfileUseCase,
    required this.updateConductorProfileUseCase,
  });
  
  // State
  ConductorProfile? _profile;
  bool _isLoading = false;
  String? _errorMessage;
  
  // Getters
  ConductorProfile? get profile => _profile;
  bool get isLoading => _isLoading;
  String? get errorMessage => _errorMessage;
  bool get hasError => _errorMessage != null;
  
  // Load profile
  Future<void> loadProfile(int conductorId) async {
    _isLoading = true;
    _errorMessage = null;
    notifyListeners();
    
    final result = await getConductorProfileUseCase(conductorId);
    
    result.fold(
      (failure) {
        _errorMessage = _mapFailureToMessage(failure);
        _profile = null;
      },
      (profile) {
        _profile = profile;
        _errorMessage = null;
      },
    );
    
    _isLoading = false;
    notifyListeners();
  }
  
  // Update profile
  Future<bool> updateProfile({
    required int conductorId,
    required String nombreCompleto,
    required String telefono,
    String? direccion,
  }) async {
    _isLoading = true;
    _errorMessage = null;
    notifyListeners();
    
    final result = await updateConductorProfileUseCase(
      conductorId: conductorId,
      nombreCompleto: nombreCompleto,
      telefono: telefono,
      direccion: direccion,
    );
    
    bool success = false;
    result.fold(
      (failure) {
        _errorMessage = _mapFailureToMessage(failure);
        success = false;
      },
      (profile) {
        _profile = profile;
        _errorMessage = null;
        success = true;
      },
    );
    
    _isLoading = false;
    notifyListeners();
    return success;
  }
  
  String _mapFailureToMessage(Failure failure) {
    if (failure is ServerFailure) {
      return 'Error del servidor: ${failure.message}';
    } else if (failure is NetworkFailure) {
      return 'Sin conexión a internet';
    } else if (failure is ValidationFailure) {
      return 'Error de validación: ${failure.message}';
    } else {
      return 'Error inesperado';
    }
  }
}
Key Points:
  • Extends ChangeNotifier (Provider pattern)
  • NO business logic (only state management)
  • Calls use cases
  • Converts failures to user messages

Data Flow Example

Let’s trace a complete flow: Loading driver profile
1

User Interaction

User taps “Ver Perfil” button in the app
2

Screen Triggers Load

// ConductorProfileScreen
context.read<ConductorProfileProvider>().loadProfile(42);
3

Provider Invokes Use Case

// ConductorProfileProvider
final result = await getConductorProfileUseCase(42);
4

Use Case Calls Repository

// GetConductorProfile
return await repository.getProfile(conductorId);
5

Repository Calls Data Source

// ConductorRepositoryImpl
final data = await remoteDataSource.getProfile(42);
6

Data Source Makes HTTP Call

// ConductorRemoteDataSourceImpl
final response = await client.get(
  Uri.parse('$baseUrl/conductor/get_profile.php?conductor_id=42')
);
7

Backend Processes Request

// backend/conductor/get_profile.php
$conductor_id = $_GET['conductor_id'];
$stmt = $pdo->prepare("SELECT * FROM conductores WHERE id = ?");
$stmt->execute([$conductor_id]);
8

Response Flows Back

Backend → Data Source (JSON)
Data Source → Repository (Map)
Repository → Use Case (Entity)
Use Case → Provider (Entity)
Provider → Screen (notifyListeners)
Screen rebuilds with data

Dependency Injection

Viax uses a Service Locator pattern for dependency injection:
// lib/src/core/di/service_locator.dart
import 'package:get_it/get_it.dart';
import 'package:http/http.dart' as http;

final sl = GetIt.instance;

void setupServiceLocator() {
  // External dependencies
  sl.registerLazySingleton(() => http.Client());
  
  // Data sources
  sl.registerLazySingleton<ConductorRemoteDataSource>(
    () => ConductorRemoteDataSourceImpl(
      client: sl(),
      baseUrl: AppConfig.conductorServiceUrl,
    ),
  );
  
  // Repositories
  sl.registerLazySingleton<ConductorRepository>(
    () => ConductorRepositoryImpl(
      remoteDataSource: sl(),
    ),
  );
  
  // Use cases
  sl.registerLazySingleton(() => GetConductorProfile(sl()));
  sl.registerLazySingleton(() => UpdateConductorProfile(sl()));
  
  // Providers
  sl.registerFactory(
    () => ConductorProfileProvider(
      getConductorProfileUseCase: sl(),
      updateConductorProfileUseCase: sl(),
    ),
  );
}
Usage:
// In main.dart
void main() {
  setupServiceLocator();
  runApp(MyApp());
}

// In widget
ChangeNotifierProvider(
  create: (_) => sl<ConductorProfileProvider>(),
  child: ConductorProfileScreen(conductorId: 42),
)

Testing Benefits

test('should calculate completion percentage correctly', () {
  // Arrange
  final profile = ConductorProfile(
    id: 1,
    conductorId: 1,
    nombreCompleto: 'Juan',
    telefono: '123',
    direccion: 'Calle 123',
    license: null, // Missing
    vehicle: null, // Missing
    estadoVerificacion: 'pendiente',
  );
  
  // Act
  final percentage = profile.completionPercentage;
  
  // Assert
  expect(percentage, 60); // 3/5 fields = 60%
});

Migration Path to Microservices

Clean Architecture makes migrating to microservices straightforward - only the data layer needs changes!
Current (Monolith):
static const String conductorServiceUrl = 'http://76.13.114.194/conductor';
Future (Microservices):
static const String conductorServiceUrl = 'https://conductor-service.viax.com/v1';
No other code changes needed! The domain and presentation layers remain unchanged.
Viax’s Clean Architecture implementation ensures maintainable, testable, and scalable code that’s ready for future growth.

Build docs developers (and LLMs) love