DEV Community

Cover image for Flutter Clean Architecture [2]: An Overview & Project Structure
marwaMejri
marwaMejri

Posted on

Flutter Clean Architecture [2]: An Overview & Project Structure

You can find the completed project repository here

In the initial section, we learn the fundamentals of clean architecture and set up the necessary folders to organize each layer. Now let's dive into these empty folders, starting with the Domain layer.
So let's start the party, enough talking.

Image description

Domain Layer

Entity

First, we add our entity that represents the weather data.
To determine which fields this class must have, we must examine the Weather API response. The following JSON response is returned by this API:

{
  "coord": {
    "lon": 10.637,
    "lat": 35.8254
  },
  "weather": [
    {
      "id": 801,
      "main": "Clouds",
      "description": "few clouds",
      "icon": "02d"
    }
  ],
  "base": "stations",
  "main": {
    "temp": 304.03,
    "feels_like": 307.27,
    "temp_min": 303.02,
    "temp_max": 304.03,
    "pressure": 1014,
    "humidity": 58
  },
  "visibility": 10000,
  "wind": {
    "speed": 5.66,
    "deg": 100
  },
  "clouds": {
    "all": 20
  },
  "dt": 1663066385,
  "sys": {
    "type": 1,
    "id": 1193,
    "country": "TN",
    "sunrise": 1663045105,
    "sunset": 1663090127
  },
  "timezone": 3600,
  "id": 2464915,
  "name": "Sousse",
  "cod": 200
}
Enter fullscreen mode Exit fullscreen mode

Except for the coord, base, and cod fields, we are interested in the majority of the fields in this answer. As a result, our entity will resemble this:

class WeatherInfoEntity extends Equatable {
  const WeatherInfoEntity({
    this.weather,
    this.main,
    this.visibility,
    this.wind,
    this.clouds,
    this.dt,
    this.sys,
    this.timezone,
    this.id,
    this.name,
    this.weatherTheme,
  });
  final List<WeatherDescriptionEntity?>? weather;
  final MainWeatherInfoEntity? main;
  final String? visibility;
  final WindInfoEntity? wind;
  final CloudsEntity? clouds;
  final String? dt;
  final SunsetSunriseEntity? sys;
  final int? timezone;
  final int? id;
  final String? name;
  final WeatherThemeEntity? weatherTheme;

  @override
  List<Object?> get props => <Object?>[
        weather,
        main,
        visibility,
        wind,
        clouds,
        dt,
        sys,
        timezone,
        id,
        name,
        weatherTheme,
      ];
}
Enter fullscreen mode Exit fullscreen mode

The WeatherInfoEntity class extends the Equatable class from the equatable plugin to allow for simple value comparisons without all the boilerplate, as Dart only supports referential equality by default. Also, if you notice that I added a new field of type WeatherThemeEntity, this field will help me specify the proper theme for a certain weather. It is not required to provide such a field, but it allows for a greater connection with the application.

class WeatherThemeEntity {

  WeatherThemeEntity({
    this.firstColor,
    this.secondColor,
  });
  Color? firstColor;
  Color? secondColor;
}

Enter fullscreen mode Exit fullscreen mode

So, the WeatherThemeEntity class will have two color type fields, in which I will add a gradient background for each type of weather.

Repository & Error handling

As indicated earlier, the repository belongs to both the domain and data layers. In fact, it will be defined in the domain first and then implemented in data. This enables 100% independence of the domain layer and also helps with developing tests for the use cases without having an actual repository implementation. So, how will the repository contract look?

Image description

In our contract, we define three methods for our weather functionality: one to retrieve weather data by coordinates, another to obtain weather for a specific city, and a third to retrieve all saved weather information locally. Logically, the return types for the first two methods should be Future, and for the final method, Future>. However, error handling is also an important consideration. We have two options: allowing exceptions to propagate and handling them elsewhere, or catching exceptions early and returning an error object from the methods.

In our architecture, I have chosen the second approach. This means that any method in the repository or use case will return both the entity and failure objects, providing a clear way to handle both successful outcomes and potential errors.

So , to implement it, I used the freezed plugin to create a sealed class named *ApiResultModel * that will return either failure with an error object or success with the specific data.

sealed classes are used to represent restricted class hierarchies, where a value can have one of a restricted set's types but not another. They are also an extension of enum classes, except that an enum constant can only have one instance, but a sealed class can have several instances.

Furthermore, the freezed plugin will generate extra functionality like copyWith and toString methods, but more importantly, it will give us access to some pattern-matching syntax.

To use this plugin, you must first install the following dependencies:

  cupertino_icons: ^1.0.5
  equatable: ^2.0.5
  freezed: ^2.3.3
  get_it: ^7.6.0
  injectable: ^2.1.2
  json_annotation: ^4.7.0
Enter fullscreen mode Exit fullscreen mode

Following that, I created a 'commondomain' subfolder under the 'core' folder. This 'commondomain' subfolder will further include an 'entities' subfolder. Additionally, I have introduced a 'based_api_result' subfolder intended for storing entities that will be shared across different sections of our project:

Image description

Creating the sealed class

The ApiResultModel sealed class will have two states: success and failure. The success state will return the data type specified when calling our sealed class. For example, if we want the method to return a WeatherInfoEntity on success, the return type should be ApiResultModel. When a problem occurs, the return type is an ErrorResultModel object, as defined in the failure state.

To create the two states, we employ factory constructors and include the necessary annotations and keywords. Make sure to add the '@freezed' annotation and the 'part' keyword at the top of the file so the plugin can build the methods specified previously:

part 'api_result_model.freezed.dart';

@freezed
class ApiResultModel<T> with _$ApiResultModel<T> {
  const factory ApiResultModel.success({required T data}) = Success<T>;

  const factory ApiResultModel.failure(
      {required ErrorResultModel errorResultEntity}) = Failure<T>;
}
Enter fullscreen mode Exit fullscreen mode

The error object includes two attributes: one for the API error status code and one for the message:

class ErrorResultModel extends Equatable {
  const ErrorResultModel({
    this.statusCode,
    this.message,
  });

  final int? statusCode;
  final String? message;

  @override
  List<Object?> get props => <Object?>[
        statusCode,
        message,
      ];
}
Enter fullscreen mode Exit fullscreen mode

Creating the repository contract with the use of the sealed class

So, in our abstract repository class, we'll have three methods: getWeatherDataByCoordinates, getWeatherDataByCity, and getAllLocalWeathers. These methods return Future>, where T is the data type:

abstract class WeatherRepository {
  Future<ApiResultModel<WeatherInfoEntity?>> getWeatherDataByCoordinates(
      {WeatherByCoordinatesRequestModel? weatherByCoordinatesRequestModel});

  Future<ApiResultModel<WeatherInfoEntity?>> getWeatherDataByCity(
      {String? cityName});

  Future<ApiResultModel<List<WeatherInfoEntity?>?>> getAllLocalWeathers();
}

Enter fullscreen mode Exit fullscreen mode

Use cases & callable classes

Any use case must have the *WeatherRepository * object, which will be provided in the constructor, as we know the use case corresponds to a repository method. In light of this, we need to add three use cases to our application: one for retrieving weather data based on coordinates, one for retrieving weather data based on city, and one for obtaining all previously stored weathers from a local database.

To begin, I will add the required repository for the GetWeatherDataByCoordinates use case:

@injectable
class GetWeatherDataByCoordinates {
  GetWeatherDataByCoordinates(this.weatherRepository);

  final WeatherRepository weatherRepository;

}
Enter fullscreen mode Exit fullscreen mode

Next, all that remains is to add the function that will retrieve the data from the repository:

@injectable
class GetWeatherDataByCoordinates {
  GetWeatherDataByCoordinates(this.weatherRepository);

  final WeatherRepository weatherRepository;

  Future<ApiResultModel<WeatherInfoEntity?>> getWeatherData(
      WeatherByCoordinatesRequestModel? weatherByCoordinatesRequestModel) {
    return weatherRepository.getWeatherDataByCoordinates(
        weatherByCoordinatesRequestModel: weatherByCoordinatesRequestModel);
  }
}
Enter fullscreen mode Exit fullscreen mode

So far, we have made good progress on our weather app. We have created an Entity, a Repository contract, and the first use case. To complete the work at the domain layer, we only need to create the remaining two use cases.

Image description

In regards to use cases, it is important to remember that each should have a return method that interacts with the repository. It makes no difference whether the logic inside the useCase retrieves a WeatherInfoEntity or something else, the goal is the same. And to improve the solution further, we could create a base class with a method that returns a generic type that can be extended by each specific use case.

creating the base use case class

To create the base useCase class, I implemented an explicit interface (in Dart, this is an abstract class) located in the core/commondomain/usecases directory since this class can be shared across multiple features of our app:

abstract class BaseParamsUseCase<Type, Request> {
  Future<ApiResultModel<Type>> call(Request? params);
}

class NoParams extends Equatable {
  @override
  List<Object> get props => <Object>[];
}

Enter fullscreen mode Exit fullscreen mode

As you can see, we added two type parameters to the BaseParamsUseCase: one represents the data type in case of success, and the Request type is for the parameters that will be passed through the use case, such as the latitude and longitude for the previous 'GetWeatherDataByCoordinates'. So, in order to implement the base class, each use case must specify the parameter Request that will be passed to the "call" method. However, if a use case does not require any parameters, we can use the NoParams class as the Request type, as in the case of 'GetAllLocalWeathers'.
The name of the 'call' method was not picked randomly. In fact, the 'call' method in the Dart programming language allows you to define a class as callable, meaning that you can use an instance of the class as if it were a function. This method is well-suited for use cases where the class names are verbs, such as 'GetWeatherDataByCoordinates'.

Extending the Base Class

Adding a use case is now very simple: each use case should implement the previous base class and override the calling method. For example, for 'GetWeatherDataByCoordinates', we implement the 'BaseParamsUseCase' with the addition of two parameters: one representing the return type of this use case and the other representing the parameters to be passed into it, which in our case is the 'WeatherByCoordinatesRequestModel', which holds the required latitude and longitude values:

class GetWeatherDataByCoordinates
    implements
        BaseParamsUseCase<WeatherInfoEntity?,
            WeatherByCoordinatesRequestModel> {
  GetWeatherDataByCoordinates(this.weatherRepository);

  final WeatherRepository weatherRepository;

  @override
  Future<ApiResultModel<WeatherInfoEntity?>> call(
      WeatherByCoordinatesRequestModel? weatherByCoordinatesRequestModel) {
    return weatherRepository.getWeatherDataByCoordinates(
        weatherByCoordinatesRequestModel: weatherByCoordinatesRequestModel);
  }
}
Enter fullscreen mode Exit fullscreen mode
class WeatherByCoordinatesRequestModel {
  WeatherByCoordinatesRequestModel({
    this.lat,
    this.lon,
  });

  final double? lat;
  final double? lon;
}
Enter fullscreen mode Exit fullscreen mode

The other two use cases will follow the same logic as the one previously mentioned:

@injectable
class GetWeatherDataByCity
    implements BaseParamsUseCase<WeatherInfoEntity?, String> {
  GetWeatherDataByCity(this.weatherRepository);

  final WeatherRepository weatherRepository;

  @override
  Future<ApiResultModel<WeatherInfoEntity?>> call(String? cityName) {
    return weatherRepository.getWeatherDataByCity(cityName: cityName);
  }
}

Enter fullscreen mode Exit fullscreen mode
@injectable
class GetAllLocalWeathers
    implements BaseParamsUseCase<List<WeatherInfoEntity?>?, NoParams> {
  GetAllLocalWeathers(this.weatherRepository);

  final WeatherRepository weatherRepository;

  @override
  Future<ApiResultModel<List<WeatherInfoEntity?>?>> call(NoParams? params) {
    return weatherRepository.getAllLocalWeathers();

  }
}
Enter fullscreen mode Exit fullscreen mode

Next up

In the next part, we will start working on the data layer containing the Repository implementation and Data Sources: Next

Top comments (0)