Get Even More Visitors To Your Blog, Upgrade To A Business Listing >>

A simple architecture for Flutter apps

Posted on Oct 18 https://pub.dev/packages/simple_architecturehttps://github.com/kodel-dynamics/simple_architectureCertain parts of an application are common to multiple applications, such as Authentication. What differs are only certain settings, such as clientId or redirectUri that are specific to each application. Therefore, there is no reason to write the same code for different applications.It is necessary that common parts of an application are reusable in other applications with no changes and, at the same time, allow the parts that actually perform the service to be interchangeable, that is, if authentication is done with the sign_in_with_apple packages and firebase_auth and, for any reason (such as a better package being built in the future, or a customer requirement needing to use Amazon Cognito or Auth0), these parts must be replaceable without any other part of the application or of reusable code is changed.This is possible through the concept of repositories. Although the repository definition is specific to databases (https://martinfowler.com/eaaCatalog/repository.html), nothing prevents the same concept from being used for any operation that performs I/O, such as reading and writing files, remote calls via REST or GraphQL and access to third-party packages such as firebase_auth. So, any part of the system that communicates in any way to any external part of the system is done through a contract (interface) that specifies what that component does (for authentication, basically it is necessary to know the authenticated user, enter and leave one account). So the parts of the system that are interchangeable are just implementations of such interfaces and adaptations to the entities that the system understands (for example, there is a class that stores user information such as id, name, email and photo url (avatar )). The authentication interface asks for this class of representation of a user, so it is the implementation's job to turn what it considers a user into the entity that the application understands (i.e., it is the repository's job to convert what represents a user to it (e.g. .: User to firebase_auth) in the entity that the application understands.This objective is met with two library features: services and dependency injection.When we talk about S.O.L.I.D., the "S" refers to single responsibility. Unfortunately, "responsibility" is a very vague thing. Is authentication a single responsibility? Authentication typically has several components such as logging into an account, logging out of an account, checking the authenticated user if present, persisting the authenticated user, taking care of tokens, etc.To make things extremely clear, it is necessary that such responsibilities are truly unique: instead of one big "authentication" responsibility, it is necessary to create a folder to group all the small features that make up an authentication system. Each feature being something to be implemented that does only that thing (that is, in an application that has an authentication system, we have features such as sign in, log out, change user name, change user photo *, etc. These are distinct features that do not interfere with other features and, ideally, can be implemented at different times (i.e.: I don't need to complete *sign in to change user name and vice versa).This objective is achieved with a granulation of features through organized folders and files, an event system to specify what is desired (e.g.: SignInUser(AuthProvider.google)) to request to sign in with a user, using the Google for this, or ChangeUserName(user.id, newName) to request a user's name change and a mediator to understand and implement such events. This mediator has access to the same dependency injection system used in services, so it becomes a simple cake recipe on how to implement such a feature, using external services to guarantee this (i.e., at this point, there are only rules business, no knowledge of external services, such as Firebase Auth, and injectable dependencies, so that such an implementation can be easily tested).When parts of the system are reusable, generally what differs in use are configurations. For example, in an authentication system, we have certain settings such as Google Client Id, Apple Service Id, Apple Redirect Uri, etc.While such settings can be injected into dependencies via parameters (during dependency registration), we can often have variable settings (e.g. a preference saved in shared_preferences or mutable settings via Firebase Remote Config.This objective is achieved with a dependency injection system for classes that store configurations and that have mechanisms for updating these configurations in the dependency injector.There is a lot of discussion about state management in Flutter. Huge (and extremely complex) frameworks are built around this, like BLoC and Riverpod. The problem is that state is something simple: you don't even need a framework for state management in Flutter, as the library provides countless ways to maintain global and local states without needing external packages, such as InheritedWidget, InheritedModel, ChangeNotifier, ValueNotifier and Stream. For example, in Firebase Auth, there is a Stream that fires during startup and whenever a user signs in or out. Any part of the system that depends on a user being authenticated or that displays information about that user (such as, for example, their photo) only needs to listen to the changes written to this stream, with a StreamBuilder. And states are that simple. Nothing more advanced is necessary in the vast majority of cases.This goal is accomplished with a simple state management system based on ValueNotifier (usable in the UI with a ValueListenableBuilder(valueListenable: $state.get(), builder: (context, authenticatedUser) => authenticatedUser == null ? LoginPage() : HomePage())). Because the state manager participates in the dependency injection system, it can have dependencies injected and can be injected into other services.To ensure that a state has a valid value during app initialization, these states must be initialized during app initialization and their initial values must be loaded. To fulfill this requirement, the state manager has load and save methods, where load loads a state (which can be a fixed default value or a value read from a local database, e.g. to maintain the state the app had during the last run) and save which can be implemented (or ignored) to save the current state of the application so that it returns to the same previous state when restarted. Additionally, a change method is used to change the state that the manager has (thus triggering the necessary events for the ValueListenableBuiler to generate a rebuild, in case the state is different from the previous one.Many times, we need to see or take care of certain things that our services cannot do (or should not do, due to the single responsibility principle, or because it is cumbersome to add monitoring to every existing call or feature, thus creating duplication of code).Things that would be interesting to implement: central exception management, where every exception is shown, both locally and remotely (with services like Sentry or Firebase Crashlytics). A system that allows you to measure how long each feature of the system takes to execute, to check for bottlenecks or even anomalies, perhaps with remote services, such as Firebase Performance. Audit logs, to know exactly what was called, what the inputs and outputs were for that call, etc.To accomplish this goal, there are classes called PipelineBehavior that intercept each system call to add such features. Each pipeline has a priority number (from 0 to infinity, with 0 running first). Then we can create pipelines that encapsulate all service calls in a try/catch and, if an exception occurs, send it, for example, to Firebase Crashlytics. Similarly, a pipeline with very high priority (say 1000), running immediately before the handler of the call actually has a time meter using Stopwatch.The S.O.L.I.D. principles They are very common in projects written by more senior and experienced people. Although not every aspect of each part of this principle makes sense today, some are extremely indispensable:1) S: Single responsibility: As described above, each written business rule must take care of one and only one responsibility. The more granular you are, the more classes there are, but the easier it is to find and fix problems. The less granular, the more responsibilities a module will have and the greater chance of problems, although fewer classes will exist. The final amount of useful code (that implements the solution to the problem) does not differ by grain.2) O: Open-Closed principle: Basically, a system must be open to modifications (e.g.: implementing Auth0 instead of Firebase Auth) without anything else in the system changing. For nothing else in the system to change, the features need to be closed (closed). In this library, we use the Polymorphic open-closed principle which is nothing more than the freedom (open) to implement things as you wish, but have a fixed contract so that the application does not need to change to accommodate such changes (closed) through interfaces injected into business rules.3) L: Liskov substitution principle: Basically it says that a class can be replaced by another class that is part of its inheritance chain without the system breaking as a result. This principle is used in the example of authentication due to a limitation of the dependency system: we have a part of the authentication system that is OAuth authentication with an external provider (Google, Facebook, Apple, etc.). Each provider has to be registered, but we cannot have an interface for both (as the dependency injector registers an interface type for each implementation). Therefore, we need to create an interface of type IOAuthService and two interfaces that inherit it such as IGoogleOAuthService and IAppleOAuthService. All of these interfaces can be interchanged without anything breaking, as they will not add or remove features (at least in terms of whoever uses them: the library). This represents exactly the same as covariance in Dart, most commonly implemented in InheritedWidgets, where the method that must be overridden updateShouldNotify comes with a covariance (i.e.: the new type you are creating can - and should - be replaced in this override and nothing will break so: bool updateShouldNotify(covariant InheritedWidget oldWidget).4) I: Interface segregation principle: This principle is about breaking down certain functionalities that certain classes have in a granular way: instead of a large contract that specifies several functionalities that may not even be usable by a target, segregation breaks such functionalities into smaller parts. For example, in services, we have purely decorative interfaces that follow this principle, such as IInitializable which requires the implementation of void initialize() and IBootable which requires the implementation of Future initializeAsync() in parts distinct from the system (the first is executed every time an injected class is instantiated, the second is specific to singletons and is executed during the library initialization process). An example of not following this principle would be to place both initialization methods in the same interface, causing the classes that implement them to ignore such decorations by implementing empty methods (that is, if you have an empty method in a class just because one interface requires this, this interface is not following the interface segregation principle).5) D: Dependency inversion principle: This principle is what allows you to write modular code, meaning that the common parts (logic) can be shared and written only once and the implementation details are free to be implemented differently for each project or even be changed in the same project without compromising functionality or rewriting. Dependency injection is used in all aspects of this library.You ain't gonna need it is a principle that says that you shouldn't implement something (or leave tips to implement in the future) of something that you won't use at the moment. This requirement is met with granular features that implement little and are independent, so that it is not necessary to implement other parts just for the sake of implementing or leaving future implementation tips in a feature.Don't Repeat Yourself is a principle that says that you should not repeat a single line of code to implement more than one functionality. This requirement is met with pipeline behaviors to implement features used at multiple points once (as opposed to, for example, adding error handling to each of the features separately). What if one is forgotten? What if you want to add functionality like reporting an error to Firebase Crashlytics? If more than one point in the code must be changed to accomplish this objective, then the code is not D.R.Y.Exceptions are almost always used as flow control, rather than representing an error from which we cannot recover. For example, the sign_in_with_apple package generates an exception of type SignInWithAppleAuthorizationException with the code AuthorizationErrorCode.canceled when the user cancels the authentication flow. This is not a good thing as this is not a mistake. The program flow is interrupted and transferred to a catch clause or, worse, if no catch is in the context, the application simply stops working, just because the user gave up signing in with an Apple account!In Flutter, there is an additional problem in using exceptions when we are working with Flutter Web: certain exceptions, such as SocketException are present in a module that we should not import in this environment (dart:io). Making our reusable code depend on extra checks whether we are in web mode or not can become quite laborious.Additionally, there are exceptions that represent the same error, but are triggered by different exceptions: for example, when there is no internet available and we try to access something remote, we may receive a SocketException, a DioException if we are using dio or, in the case of authentication, a FirebaseAuthException or even a PlatformException. It all boils down to the same failure: networkRequestFailed and it should be easier to deal with this in the UI: instead of handling 4 different types of exceptions (and others in the future when more functionality is implemented), services could just return a " result loader", i.e., a class that contains a success state, with the value returned by the service (e.g., the authenticated user), or a failure state, which contains only a description of the failure (e.g., a SignInFailure enum which contains all problems that may occur during authentication and an unknown for anything else unexpected). So, in our UI (or at any other point), we don't depend on classes and exceptions, but a simple enum (which is very interesting because Dart warns us when we try to use an enum in a switch and we don't cover all the possibilities existing).To do this, we can use a class like Result() with the constructors Result.success(TValue value) and Result.failure(TFailure failure, [Object? exception, StackTrace? stackTrace ]).As an example, we will implement a complete authentication system, using this library and all existing concepts.The features folder will contain all the features of our system (currently, we only have the feature AUTH).Within each feature, we have:Additionally, we have a more generic feature for monitoring errors and performance, through pipeline behaviors.In the infrastructure folder outside of features we have the actual implementation of the contracts we need (which are login implementation with google_sign_in, sign_in_with_apple and firebase_auth, in addition to our login registration repository with package isar).Everything under features can be safely copied and pasted into other projects.Everything under infrastructure can be copied and pasted, if the implementation is the same (i.e. if other projects also use Firebase Auth, etc.).The app launch will look like this:This code adds the authentication settings that are specific to each project (the GoogleClientId is obtained from the file generated by the Firebase CLI, the AppleServiceId is obtained from the settings generated on the website where we configure login via Apple (developer.apple.com) and the AppleRedirectUri Specifies the Uri that OAuth authentication uses to complete authentication.Afterwards, the services are registered:This is the complete code for the feature sign in:First, we implement equality by value for events, requests, entities, etc. using the great dart_mappable library. This is necessary to avoid rebuilds in our interface or triggering services when nothing has changed. Dart makes a comparison by reference, that is, one object is only equal to another if they point to the same object in memory. When we use immutability, we always generate a copy of an object, with possible changes. This copy, for Dart, is always different from the first (even if the values are the same). dart_mappable then implements value comparison (i.e., compares each field within a class to verify that they represent exactly the same entity). We use dart_mappable because it doesn't influence what you can do with your class (freezed for example prevents or hinders certain features like inheritance, generics, methods, etc.) and it also implements several other useful features, like copyWith and serialization (toMap and toJson).Using the principles described in Vertical Slice Architecture (https://www.jimmybogard.com/vertical-slice-architecture/), we try to keep everything related to a feature within the same file (only separating implementations that cannot be copied and glued securely to other projects or entities that are generally used without needing everything else). So our file contains:1) A SignInFailure enum that represents the possible errors that can occur during a sign in.2) A Request that represents the desire to sign in (the UI will trigger this Request, informing whether you want to authenticate via Google or Apple, and everything will be done "automatically").3) A handler for this request, which will implement the sign in logic per se. This logic emits events of type SignInAuthStageNotification so that the UI can show what is happening and then tries to authenticate with the specified provider (Google or Apple), which will emit an error or a credential (containing user data and access tokens ). If successful, this will be sent to Firebase Auth to generate a truly authenticated user. Finally, we send this almost ready-made user to the repository, so that we can write information about the user and the login made to the database.Note that some things are still missing, such as reporting this login to Firebase Analytics, correcting the fact that signing in with Apple only sends the user name on the first authentication (our logic must consider this provider limitation and adjust the authenticated user accordingly , as a business rule).But, basically, we have here all the requirements that we defined, such as reusability, granularity of features, testability, etc. and we can add or remove features very simply without even having to change the parts that are already ready. For example, to implement login logging in Firebase Analytics, we can simply write an IBootable service that registers itself as an listener of the authentication state (AuthState) and, when it occurs, does what it needs to do (which is basically calling a method saying that the user with ID X has authenticated). We can, additionally, emit a specific event at the end of the authentication process (such as UserHasSignedIn(Principal)), so any module that may be written in the future can listen to this event and do what it wants, without the rest of the application have this knowledge.The possibilities and freedoms of implementation are open.Some extra details on implementations or usage:google_sign_in_servicefirebase_auth_service.dart:login_page:Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well Confirm For further actions, you may consider blocking this person and/or reporting abuse Ahana Sharma - Oct 13 ChunTing Wu - Oct 16 ASHISH RANJAN - Oct 11 Leonardo Camargo - Oct 12 Once suspended, jckodel will not be able to comment or publish posts until their suspension is removed. Once unsuspended, jckodel will be able to comment and publish posts again. Once unpublished, all posts by jckodel will become hidden and only accessible to themselves. If jckodel is not suspended, they can still re-publish their posts from their dashboard. Note: Once unpublished, this post will become invisible to the public and only accessible to J.C.Ködel. They can still re-publish the post if they are not suspended. Thanks for keeping DEV Community safe. Here is what you can do to flag jckodel: jckodel consistently posts content that violates DEV Community's code of conduct because it is harassing, offensive or spammy. Unflagging jckodel will restore default visibility to their posts. DEV Community — A constructive and inclusive social network for software developers. With you every step of your journey. Built on Forem — the open source software that powers DEV and other inclusive communities.Made with love and Ruby on Rails. DEV Community © 2016 - 2023. We're a place where coders share, stay up-to-date and grow their careers.



This post first appeared on VedVyas Articles, please read the originial post: here

Share the post

A simple architecture for Flutter apps

×

Subscribe to Vedvyas Articles

Get updates delivered right to your inbox!

Thank you for your subscription

×