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
- Repositories (Contracts)
- Use Cases
Entities are business objects that encapsulate enterprise business rules.Key Points:
// 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,
);
}
}
- Immutable objects (all fields final)
- Business logic methods (completionPercentage, canSubmitForApproval)
- No dependencies on UI or data layers
- Pure Dart code
Repository Interfaces define contracts for data access without implementation.Key Points:
// lib/src/features/conductor/domain/repositories/conductor_repository.dart
import 'package:dartz/dartz.dart';
import '../entities/conductor_profile.dart';
import '../../../../core/error/failures.dart';
abstract class ConductorRepository {
// Get driver profile
Future<Either<Failure, ConductorProfile>> getProfile(int conductorId);
// Update profile information
Future<Either<Failure, ConductorProfile>> updateProfile({
required int conductorId,
required String nombreCompleto,
required String telefono,
String? direccion,
});
// Update driver license
Future<Either<Failure, Unit>> updateDriverLicense({
required int conductorId,
required String numeroLicencia,
required DateTime fechaExpedicion,
required DateTime fechaVencimiento,
required String categoria,
});
// Submit for approval
Future<Either<Failure, Unit>> submitForApproval(int conductorId);
}
- Abstract class (interface)
- Returns
Either<Failure, T>for error handling - No implementation details
- Defines what, not how
Use Cases encapsulate application business rules - one use case per action.More Use Cases:Key Points:
// lib/src/features/conductor/domain/usecases/get_conductor_profile.dart
import 'package:dartz/dartz.dart';
import '../entities/conductor_profile.dart';
import '../repositories/conductor_repository.dart';
import '../../../../core/error/failures.dart';
class GetConductorProfile {
final ConductorRepository repository;
GetConductorProfile(this.repository);
Future<Either<Failure, ConductorProfile>> call(int conductorId) async {
// Validation logic
if (conductorId <= 0) {
return Left(ValidationFailure('Invalid conductor ID'));
}
// Delegate to repository
return await repository.getProfile(conductorId);
}
}
// Update profile
class UpdateConductorProfile {
final ConductorRepository repository;
UpdateConductorProfile(this.repository);
Future<Either<Failure, ConductorProfile>> call({
required int conductorId,
required String nombreCompleto,
required String telefono,
String? direccion,
}) async {
// Validation
if (nombreCompleto.trim().isEmpty) {
return Left(ValidationFailure('Name cannot be empty'));
}
if (telefono.trim().isEmpty) {
return Left(ValidationFailure('Phone cannot be empty'));
}
// Execute
return await repository.updateProfile(
conductorId: conductorId,
nombreCompleto: nombreCompleto,
telefono: telefono,
direccion: direccion,
);
}
}
- Single responsibility (one action)
- Contains validation logic
- Orchestrates repository calls
- Returns domain entities
- ✅ 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
- Models
- Repository Impl
Data Sources handle actual communication with external services.Key Points:
// 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}');
}
}
}
- Concrete implementations
- HTTP calls, database queries, etc.
- Throws technical exceptions
- Returns raw data (Maps, Lists)
Models extend entities with serialization capabilities.Key Points:
// lib/src/features/conductor/data/models/conductor_profile_model.dart
import '../../domain/entities/conductor_profile.dart';
import 'driver_license_model.dart';
import 'vehicle_model.dart';
class ConductorProfileModel extends ConductorProfile {
const ConductorProfileModel({
required int id,
required int conductorId,
required String nombreCompleto,
required String telefono,
String? direccion,
DriverLicense? license,
Vehicle? vehicle,
required String estadoVerificacion,
}) : super(
id: id,
conductorId: conductorId,
nombreCompleto: nombreCompleto,
telefono: telefono,
direccion: direccion,
license: license,
vehicle: vehicle,
estadoVerificacion: estadoVerificacion,
);
// From JSON (API response to Model)
factory ConductorProfileModel.fromJson(Map<String, dynamic> json) {
return ConductorProfileModel(
id: json['id'] ?? 0,
conductorId: json['conductor_id'] ?? 0,
nombreCompleto: json['nombre_completo'] ?? '',
telefono: json['telefono'] ?? '',
direccion: json['direccion'],
license: json['license'] != null
? DriverLicenseModel.fromJson(json['license'])
: null,
vehicle: json['vehicle'] != null
? VehicleModel.fromJson(json['vehicle'])
: null,
estadoVerificacion: json['estado_verificacion'] ?? 'pendiente',
);
}
// To JSON (Model to API request)
Map<String, dynamic> toJson() {
return {
'id': id,
'conductor_id': conductorId,
'nombre_completo': nombreCompleto,
'telefono': telefono,
'direccion': direccion,
'estado_verificacion': estadoVerificacion,
};
}
// Convert Model to Entity
ConductorProfile toEntity() {
return ConductorProfile(
id: id,
conductorId: conductorId,
nombreCompleto: nombreCompleto,
telefono: telefono,
direccion: direccion,
license: license,
vehicle: vehicle,
estadoVerificacion: estadoVerificacion,
);
}
}
- Extends domain entities
- Handles JSON serialization
- Knows about API structure
- Converts between Model and Entity
Repository Implementation connects data sources to domain contracts.Key Points:
// lib/src/features/conductor/data/repositories/conductor_repository_impl.dart
import 'package:dartz/dartz.dart';
import '../../domain/entities/conductor_profile.dart';
import '../../domain/repositories/conductor_repository.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/error/exceptions.dart';
import '../datasources/conductor_remote_datasource.dart';
import '../models/conductor_profile_model.dart';
class ConductorRepositoryImpl implements ConductorRepository {
final ConductorRemoteDataSource remoteDataSource;
ConductorRepositoryImpl({required this.remoteDataSource});
@override
Future<Either<Failure, ConductorProfile>> getProfile(int conductorId) async {
try {
// Call data source
final profileData = await remoteDataSource.getProfile(conductorId);
// Convert to model, then to entity
final model = ConductorProfileModel.fromJson(profileData);
final entity = model.toEntity();
return Right(entity);
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on NetworkException catch (e) {
return Left(NetworkFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure(e.toString()));
}
}
@override
Future<Either<Failure, ConductorProfile>> updateProfile({
required int conductorId,
required String nombreCompleto,
required String telefono,
String? direccion,
}) async {
try {
final data = {
'conductor_id': conductorId,
'nombre_completo': nombreCompleto,
'telefono': telefono,
if (direccion != null) 'direccion': direccion,
};
final profileData = await remoteDataSource.updateProfile(data);
final model = ConductorProfileModel.fromJson(profileData);
return Right(model.toEntity());
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure(e.toString()));
}
}
// Other methods...
}
- Implements domain repository interface
- Coordinates data sources
- Converts exceptions to failures
- Returns domain entities (not models)
- ✅ 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
- Screens
Providers manage UI state and invoke use cases.Key Points:
// 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';
}
}
}
- Extends ChangeNotifier (Provider pattern)
- NO business logic (only state management)
- Calls use cases
- Converts failures to user messages
Screens are pure UI components.Key Points:
// lib/src/features/conductor/presentation/screens/conductor_profile_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/conductor_profile_provider.dart';
class ConductorProfileScreen extends StatefulWidget {
final int conductorId;
const ConductorProfileScreen({required this.conductorId});
@override
State<ConductorProfileScreen> createState() => _ConductorProfileScreenState();
}
class _ConductorProfileScreenState extends State<ConductorProfileScreen> {
@override
void initState() {
super.initState();
// Load data on init
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<ConductorProfileProvider>().loadProfile(widget.conductorId);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Perfil del Conductor')),
body: Consumer<ConductorProfileProvider>(
builder: (context, provider, child) {
// Loading state
if (provider.isLoading) {
return Center(child: CircularProgressIndicator());
}
// Error state
if (provider.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error, size: 64, color: Colors.red),
SizedBox(height: 16),
Text(provider.errorMessage!),
ElevatedButton(
onPressed: () => provider.loadProfile(widget.conductorId),
child: Text('Reintentar'),
),
],
),
);
}
// Success state
final profile = provider.profile;
if (profile == null) {
return Center(child: Text('No hay datos'));
}
return SingleChildScrollView(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Profile completion card
Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
children: [
Text(
'Completitud del Perfil',
style: Theme.of(context).textTheme.titleLarge,
),
SizedBox(height: 8),
LinearProgressIndicator(
value: profile.completionPercentage / 100,
),
SizedBox(height: 4),
Text('${profile.completionPercentage}%'),
],
),
),
),
SizedBox(height: 16),
// Profile details
_buildInfoCard('Nombre', profile.nombreCompleto),
_buildInfoCard('Teléfono', profile.telefono),
if (profile.direccion != null)
_buildInfoCard('Dirección', profile.direccion!),
// License info
if (profile.license != null) ..[
SizedBox(height: 16),
Text('Licencia de Conducción'),
_buildInfoCard('Número', profile.license!.numeroLicencia),
],
// Submit button
if (profile.canSubmitForApproval) ..[
SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
// Submit for approval
},
child: Text('Enviar para Aprobación'),
),
),
],
],
),
);
},
),
);
}
Widget _buildInfoCard(String label, String value) {
return Card(
child: ListTile(
title: Text(label),
subtitle: Text(value),
),
);
}
}
- Pure UI code
- Consumes provider state
- Handles loading/error/success states
- No business logic
Data Flow Example
Let’s trace a complete flow: Loading driver profileScreen Triggers Load
// ConductorProfileScreen
context.read<ConductorProfileProvider>().loadProfile(42);
Provider Invokes Use Case
// ConductorProfileProvider
final result = await getConductorProfileUseCase(42);
Repository Calls Data Source
// ConductorRepositoryImpl
final data = await remoteDataSource.getProfile(42);
Data Source Makes HTTP Call
// ConductorRemoteDataSourceImpl
final response = await client.get(
Uri.parse('$baseUrl/conductor/get_profile.php?conductor_id=42')
);
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]);
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(),
),
);
}
// In main.dart
void main() {
setupServiceLocator();
runApp(MyApp());
}
// In widget
ChangeNotifierProvider(
create: (_) => sl<ConductorProfileProvider>(),
child: ConductorProfileScreen(conductorId: 42),
)
Testing Benefits
- Unit Tests (Domain)
- Integration Tests (Data)
- Widget Tests (Presentation)
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%
});
test('should return profile when API call succeeds', () async {
// Arrange
final mockClient = MockClient();
when(mockClient.get(any)).thenAnswer((_) async =>
http.Response('{"success": true, "profile": {...}}', 200)
);
final dataSource = ConductorRemoteDataSourceImpl(
client: mockClient,
baseUrl: 'http://test.com',
);
// Act
final result = await dataSource.getProfile(1);
// Assert
expect(result, isA<Map<String, dynamic>>());
});
testWidgets('should show loading indicator', (tester) async {
// Arrange
final provider = MockConductorProfileProvider();
when(provider.isLoading).thenReturn(true);
// Act
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: provider,
child: MaterialApp(
home: ConductorProfileScreen(conductorId: 1),
),
),
);
// Assert
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
Migration Path to Microservices
Clean Architecture makes migrating to microservices straightforward - only the data layer needs changes!
static const String conductorServiceUrl = 'http://76.13.114.194/conductor';
static const String conductorServiceUrl = 'https://conductor-service.viax.com/v1';
Viax’s Clean Architecture implementation ensures maintainable, testable, and scalable code that’s ready for future growth.