https://github.com/crcdng/weather_app

A lightweight clean architecture example in Flutter

https://github.com/crcdng/weather_app

Science Score: 26.0%

This score indicates how likely this project is to be science-related based on various indicators:

  • CITATION.cff file
  • codemeta.json file
    Found codemeta.json file
  • .zenodo.json file
    Found .zenodo.json file
  • DOI references
  • Academic publication links
  • Academic email domains
  • Institutional organization owner
  • JOSS paper metadata
  • Scientific vocabulary similarity
    Low similarity (11.1%) to scientific vocabulary
Last synced: 6 months ago · JSON representation

Repository

A lightweight clean architecture example in Flutter

Basic Info
  • Host: GitHub
  • Owner: crcdng
  • License: mit
  • Language: Dart
  • Default Branch: main
  • Homepage:
  • Size: 9.66 MB
Statistics
  • Stars: 5
  • Watchers: 1
  • Forks: 0
  • Open Issues: 0
  • Releases: 0
Created almost 2 years ago · Last pushed 6 months ago
Metadata Files
Readme License

README.md

Flutter Lightweight Clean Architecture Example

This is a lightweight example (with a lot of documentation) for a clean architecture and test-driven app in Flutter. It fetches weather data from OpenWeatherMap and displays it. The example is adapted from this tutorial - see other sources and inspirations below. Since 2024, the official Flutter documentation contains an approach that is somewhat similar but not identical to the one described here.

Tested on Android and macOS.

Architecture overview

diagram

The application has three layers: Presentation, Domain and Data.

Presentation layer

The user interface consists of one WeatherScreen. The WeatherNotifier is a ChangeNotifier. It has a WeatherEntity and gets a GetWeatherUsecase via the constructor. It calls the use case with the name of the city, sets the entity and notifies the ListenableBuilder widget in the WeatherScreen, which then rebuilds. When the TextField changes, WeatherNotifierProvider provides the WeatherNotifier's getCurrentWeather() method. A debounce mechanism limits the number of calls.

The WeatherNotifierProvider, an Inherited Widget, provides the WeatherNotifier. This is set up in main.dart.

Domain layer

Clean architecture mandates that the central layer does not depend either on the user interface (presentation layer) or on the remote API (data layer).

A use case represents a user action. The GetWeatherUsecase receives (the abstract) WeatherRepository passed in via the constructor and calls its method. The repository, in turn, either returns a Failure object or a WeatherEntity. It is separated into an abstract class inside the Domain layer that defines the contract (interface) and a concrete class in the Data layer that implements it. This technique implements the Dependency Inversion Principle and embodies the Dependency Rule: dependencies point "inwards" toward higher-level policies, that is the Domain layer.

WeatherEntity is an immutable pure data class that contains the fields we are interested in. Although we don't test it directly, it uses the equatable package so that instances of WeatherModel can be compared in tests.

Use cases are implemented as callable classes, with a call method and a common interface. This could also be done with an abstract superclass and additional work on the parameters going into the call method. I avoid that approach because it comes with additional boilerplate and I don't see the benefits here.

Data layer

The data layer is responsible for wrapping data sources, in this case the OpenWeatherMap API.

The WeatherModel class extends WeatherEntity. It adds a constructor to create an instance from a subset of the JSON format coming from the DataSource. It also has a method to transform itself into a WeatherEntity.

The WeatherRemoteDataSource takes an http client passed in its constructor. Its method returns a Future of a WeatherModel converted from JSON. To do this, it talks to the remote OpenWeatherMap API, for which you need to sign up for a free API key. I am keeping all the required information inside the WeatherRemoteDataSource class. As with the GetWeatherUsecase above, I do not add a layer of abstraction by separating it into an abstract superclass and concrete subclass as suggested by some other tutorials.

The WeatherRepositorImpl class implements the contract of the WeatherRepository. It has a WeatherRemoteDataSource passed into the constructor and calls its method. It then uses try/catch to either * transform the WeatherModel returned from successful API calls into a WeatherEntity (Right side of Either) or * transform exceptions into Failure objects (Left side of Either). This keeps exceptions in the data layer.

Common objects and functions

The API call to retrieve the current weather for a city is returned in urls.dart: https://api.openweathermap.org/data/2.5/weather?q=<CITY NAME>&units=metric&appid=<API KEY>. I am adding the units=metric parameter in order to retrieve the temperature in degrees Celsius. Because the API key should not be stored in a code repository, it is injected from the environment. Thus, the app must be called like this: flutter run --dart-define OWM_API_KEY=<API KEY> --hot. In production, the key would be provided by the user at the start of the app / from a settings screen.

Handling API keys in Flutter has various aspects, see: https://codewithandrea.com/articles/flutter-api-keys-dart-define-env-files/.

Failure is an abstract class, extended by concrete Failures, e.g. ServerFailure. Each Failure subclass mirrors a corresponding Exception.

main.dart

In main.dart, the WeatherNotifierProvider is inserted into the Widget Tree. The classes further down the dependency chain are also explicitly instantiated here: WeatherNotifier, GetWeatherUsecase and WeatherRepositoryImpl. Although the amount of nesting appears verbose on first sight, it provides clarity about the dependencies, compared to 'magic' that hides them away which comes with using a service locator.

Order of implementation: Domain -> Data -> Presentation

In this apoproach, it is essential to start with the Domain layer because the other layers depend on it. Then we implement the Data layer, which contains most of the implementation and requires more work handling API responses, writing tests and dealing with errors. The Presentation layer with the user interface and Flutter state management comes last (or can be designed in parallel).

Implement the Domain Layer

  1. WeatherEntity
  2. WeatherRepository
  3. Failure
  4. GetWeatherUsecase (TDD) (alternatively start from here)

Implement the Data Layer

  1. WeatherModel (TDD)
  2. WeatherRemoteDataSource (TDD)
  3. ServerException, Urls
  4. WeatherRepositoryImpl (TDD)
  5. ServerFailure, ConnectionFailure

Implement the Presentation Layer

  1. WeatherNotifier (TDD)
  2. WeatherNotifierProvider (TDD)
  3. main.dart
  4. WeatherScreen (TDD)

Order of reading the code: Domain -> Data | Presentation

"Code is more often read than written" is a tenet of programmer wisdom. Therefore, your codebase must be easily readable. As critics have pointed out, highly abstract, multi-layered architectures are often difficult to navigate for someone unfamiliar with the codebase. There are dozens of directories and hundreds of files to be ingested. This doesn't apply to this lightweight clean approach, because the architecture determines a significant part of the implementation. For example, if you know WeatherEntity in the Domain Layer, you already know that WeatherModel in the Data layer converts the data representation (here from JSON) to create the Entity. No need to read it. Similarly, the state management contains minimal code that notifies the View either about the Entity or Failure objects - there is no need to read it either. As a result, the recommended reading list is:

In the Domain Layer: WeatherEntity and GetWeatherUsecase. Entities and Use Cases form the core of the application. They don't contain any implementation details. After looking at a few lines of code, you should have an idea what the app does.

In the Data Layer: WeatherRepositoryImpl. Here, you learn what the Data Layer returns after requests to the data storage succeed or errors occur, respectively. For implementation details look at WeatherRemoteDataSource.

In the Presentation Layer: WeatherScreen. This is the widget hierarchy that describes the user interface.

Finally, have a look at the tests or run them to get an idea of what is tested.

That's it.

Tests

The annotation "TDD" indicated which classes are tested via Test-Driven Development (write tests first, then code). You can also write code first and tests later, whatever you prefer.

get_weather_usecase_test.dart tests whether the (mocked) WeatherRemoteDataSource is called and whether Right(WeatherEntity) and Left(ServerFailure) are returned from the use case.

weather_model_test.dart tests whether the WeatherModel is a subclass of WeatherEntity and whether the model returned from its JSON factory constructor is assembled correctly.

remote_datasource_test.dart tests whether the WeatherRemoteDataSource returns a WeatherModel if the API call is successful and throws various exceptions otherwise. The http.Client is mocked.

weather_repository_impl_test.dart tests whether the WeatherRepositoryImpl returns a Right(WeatherEntity) from a WeatherModel passed in by the WeatherRemoteDataSource and otherwise turns exceptions into objects. It distinguishes between:

  • city not found, which happens while typing the city name: CityNotFoundException -> Left(CityNotFoundFailure)
  • wrong or missing API key: ApiKeyException -> Left(ApiKeyFailure)
  • other Server errors: ServerException -> Left(ServerFailure)
  • no Internet connection: SocketException -> Left(SocketFailure)

The WeatherRemoteDataSource is mocked.

weather_notifier_test.dart tests the state management: is WeatherNotifier calling the (mocked) GetWeatherUsecase? Are listeners notified? Are the fields updated with a WeatherEntity or with a Failure?

weather_screen_test.dart consists of widget tests. To get the test green that checks if the weather info appears on the screen, it is necessary to mock/stub/fake WeatherNotifier, which is a ChangeNotifier that updates the widget tree via the InheritedWidget. It also tests Failures that display a message, whereas other Failures don't.

weather_notifier_provider.dart currently misses some tests, that's why I included a failing test as a reminder. It's not clear to me how / what to test in an Inherited Widget.

The folder integration_test has an integration test. Because it calls the remote OpenWeatherMap API, it must be run with the API key

flutter test integration_test --dart-define OWM_API_KEY=<API KEY>

Pure data classes, abstract classes and third party dependencies are not tested. The Presentation layer has both unit tests for the WeatherNotifier state management class and widget tests for the WeatherScreen. The test/utils folder contains dummy JSON data that we need in more than one test and a reader helper function.

I am using the mocktail package for mocking dependencies.

App platform preparation

If you deploy to macOS, edit both macos/Runner/DebugProfile.entitlements and macos/Runner/Release.entitlements and add the following key:

<!-- Required to fetch data from the internet. --> <key>com.apple.security.network.client</key> <true/>

If you deploy to Android, edit android/app/src/main/AndroidManifest.xml and add the following key (the debug and profile versions already have this permission):

<!-- Required to fetch data from the internet. --> <uses-permission android:name="android.permission.INTERNET" />

Why "Lightweight Clean Architecture"?

"All problems in computer science can be solved by another level of indirection, except for the problem of too many layers of indirection.", attributed to David Wheeler.

Clean architecture already has more than handful of concepts to grasp. The Flutter clean architecture tutorials I visited are full of abstractions and start with lots of directories. On the other hand there are developers with strong opinions on social media who rage against clean architecture for various reasons. Unfortunately these extremes drone out the fact that as a developer you need to find a balance between solid architecture and pragmatism.

The goal for this example is to be lightweight yet solid. I do not use injection containers, hooks, API wrappers, barrel files, code generation, state management libraries or any of the third party packages some authors of tutorials just add in without explanation. For state management, I started out with the recommended 'Provider' approach https://docs.flutter.dev/data-and-backend/state-mgmt/simple but decided to rewrite in pure Flutter later. With the Model-View approach described above in place the refactoring was easy. I also use the equatable package to simplify object comparison in tests and the Either construct from the fpdart library in order to transform exceptions into types inside the repository. All in all the app has three external dependencies (fpdart, equatable, http) and one development dependency (mocktail).

The Flutter state management notifies the user interface (View) of changes in the underlying data and also triggers changes caused by a user interactions which are then handled in the Domain layer. These mechanisms implement reactivity and they only require a minimal amount of code which is part of the Presentation layer.

I decided not to write additional abstract superclasses of Use Cases, as seen in some tutorials, to avoid subsequent modelling of parameters, which adds a lot of complexity and little benefit in my opinion. The same thinking applies to the Data Sources, which also could have been abstracted further by providing an interface. Because the app only has one feature - getting the current weather - I decided to leave out the "feature" directory, and because the example is minimal, I put all files that belong to a layer into their respective directory: "presentation", "domain" and "data". There is a small "common" directory for items used across the layers, such as error types or constants.

A note on naming

Some of the terms used in the programming literature are interpreted differently by different authors, and there seems to be quite a bit of confusion about naming. As an example, the management of reactive state in Flutter is sometimes called "business logic". But "business logic" is traditionally known as the core logic of an application, without any user interface or low level data handling. In the clean code approach, this is located in the Domain layer, structured into Entities and Use Cases. Furthermore, the question if the terms 'MVC' or 'MVVM' apply to Flutter has been vehemently debated in the community.

Benefits of the Approach

For me, the benefit of this lightweight clean architecture is that it provides a structure in which one knows where to look for certain parts and what to test. It is testable because its dependencies are passed into classes via constructors. We can test layer by layer and mock out the layers that other layers depend on. It is possible to add, swap and remove elements of the architecture horizontally (user interface, databases, APIs) and vertically (features). Some of these benefits likely become only obvious in a larger project, but keeping the example minimal helps in understanding the architecture.

Outlook

A few ideas to extend the example:

  • Support different temperature units (a Celsius / Kelvin switch)
  • Load the weather icon from the API (this was done from inside the ui in the tutorial source)
  • Enter the OpenWeather API key on startup, store it and offer a settings screen to change it
  • Store a list of favourite cities
  • Support different languages
  • Adapt to different platforms (I tested on macOS and Android)
  • Handle loading state / long loading (this is done with Bloc in the original tutorial)
  • Expand the app functionality with other data from the OpenWeather API, such as forecasts
  • Design a nice UI / weather animations, e.g inspired by https://www.youtube.com/watch?v=MMq4wkeHkPc
  • Switch out the API with a different one as an exercise

Resources

This example code is influenced by these sources:

  • https://www.youtube.com/watch?v=g2Mup12MccU / https://betterprogramming.pub/flutter-clean-architecture-test-driven-development-practical-guide-445f388e8604 (main source)

  • https://www.goodreads.com/book/show/18043011-clean-architecture

  • https://www.goodreads.com/en/book/show/387190

  • https://www.manning.com/books/good-code-bad-code

  • https://www.packtpub.com/en-de/product/flutter-design-patterns-and-best-practices-9781801072649

  • https://resocoder.com/2019/08/27/flutter-tdd-clean-architecture-course-1-explanation-project-structure/

  • https://codewithandrea.com/articles/comparison-flutter-app-architectures/

  • https://codewithandrea.com/articles/flutter-api-keys-dart-define-env-files/

  • https://stackoverflow.com/questions/51791501/how-to-debounce-textfield-onchange-in-dart

License

MIT License

Owner

  • Name: crcdng
  • Login: crcdng
  • Kind: user

may the farce be with you

GitHub Events

Total
  • Watch event: 2
  • Push event: 24
Last Year
  • Watch event: 2
  • Push event: 24

Dependencies

macos/Podfile cocoapods
macos/Podfile.lock cocoapods
  • FlutterMacOS 1.0.0
android/app/build.gradle maven
android/build.gradle maven
pubspec.lock pub
  • async 2.11.0
  • boolean_selector 2.1.1
  • characters 1.3.0
  • clock 1.1.1
  • collection 1.18.0
  • equatable 2.0.5
  • fake_async 1.3.1
  • file 7.0.0
  • flutter 0.0.0
  • flutter_driver 0.0.0
  • flutter_lints 3.0.1
  • flutter_test 0.0.0
  • fpdart 1.1.0
  • fuchsia_remote_debug_protocol 0.0.0
  • http 1.2.1
  • http_parser 4.0.2
  • integration_test 0.0.0
  • leak_tracker 10.0.0
  • leak_tracker_flutter_testing 2.0.1
  • leak_tracker_testing 2.0.1
  • lints 3.0.0
  • matcher 0.12.16+1
  • material_color_utilities 0.8.0
  • meta 1.11.0
  • mocktail 1.0.3
  • nested 1.0.0
  • path 1.9.0
  • platform 3.1.4
  • process 5.0.2
  • provider 6.1.2
  • sky_engine 0.0.99
  • source_span 1.10.0
  • stack_trace 1.11.1
  • stream_channel 2.1.2
  • string_scanner 1.2.0
  • sync_http 0.3.1
  • term_glyph 1.2.1
  • test_api 0.6.1
  • typed_data 1.3.2
  • vector_math 2.1.4
  • vm_service 13.0.0
  • web 0.5.1
  • webdriver 3.0.3
pubspec.yaml pub
  • flutter_lints ^3.0.0 development
  • flutter_test --- !ruby/hash:ActiveSupport::HashWithIndifferentAccess sdk: flutter development
  • integration_test --- !ruby/hash:ActiveSupport::HashWithIndifferentAccess sdk: flutter development
  • mocktail ^1.0.3 development
  • equatable ^2.0.5
  • flutter --- !ruby/hash:ActiveSupport::HashWithIndifferentAccess sdk: flutter
  • fpdart ^1.1.0
  • http ^1.2.1
  • provider ^6.1.2