Skip to content

Instantly share code, notes, and snippets.

@csasbach
Created September 28, 2023 10:08
Show Gist options
  • Save csasbach/b34496120a2e4743497ba7733aaff6c6 to your computer and use it in GitHub Desktop.
Save csasbach/b34496120a2e4743497ba7733aaff6c6 to your computer and use it in GitHub Desktop.

Dart / Flutter

Why?

The fundamental reason we use Dart is simply that it is the language used to develop for Flutter. The reason we use Flutter is that it is a framework developed and supported by Google for

"building beautiful, natively compiled, multi-platform applications from a single codebase"

The Flutter platform provides Dart classes called Widgets that are used to describe elements that are rendered in the user interface. Beyond these, Dart is a statically typed language that is also used to write any other classes your application requires.

There are rare cases where Kotlin or Swift may need to be used to cover the odd functional gap on one platform or another.

Flutter uses Material Design out of the box which is an open-source design standard. This gives developers a leg up on designing highly attractive and usable interfaces.

Docs

To get started with Dart, read the documentation provided below. It explains everything you need to know about code style, the type system, package structure, etc. This coupled with an understanding of our design principles should give you an understanding of how to code effectively in Dart.

Dart Language Tour

Flutter Docs

Flutter Videos

Unit Tests and Widget Tests (Widget testing can be hard, but it doesn't need to be)

Integration Testing (Coming soon!)

Dart Coding Guidelines

Overdoing It

Given that Dart already allows us to use any class like it’s an interface, and because many of the classes that make up a pattern like MVC will only ever have one implementation at a time, following all of these principles to the letter can, in many situations, be overdoing it.

For example, most Widgets, Controllers, Services, Bindings, DataAccess classes will have one API for the lifetime of the product. So there really is no need to bother providing more types just to serve as containers for the abstraction. In other words, if the implementations are all at the right level of abstraction (and by virtue of the higher MVC design pattern, you can usually assume they are) then you don’t need things like interface segregation.

The reason for this again goes back to the implements keyword and the fact that it allows you to inject testable mocks using the API the primary implementation already provides. The guidelines below are to help you map 1:1 how you can follow each principle to the letter using Dart. However, don’t take them as directives. Judge for yourself what the situation requires and strive to minimize complexity.

Finally, to give an example of when you would go as far as to implement interface segregation is when you are creating deep enough complexity where it becomes reasonable to segregate general-purpose logic and use-case specific logic into separate types, where the general-purpose logic uses generics constrained on segregated interface types, allowing you to construct implementations using any number of classes and/or mixins that implement those types as required. Then use dependency injection to choose which implementations to inject for a given use case at run time. That allows the calling code to operate without any knowledge of the lower-level code.

No special guidance, follow this principle as is typical for any language that has classes: Each class should only have one reason to change.

Higher Order Functions

In Dart, at the function/method level, we can use function definitions as types. This means we can create what are referred to as 'higher order' functions, functions that take other functions as parameters. This relates directly to the open-closed principle. With higher order functions we can delegate logic, dependencies, and I/O to other functions; allowing the current function to focus on its singular purpose. This gets us closer to a pure function.

/// Testable signature for _fetchQuote
///
/// Only visible to tests
///
/// _fetchQuote() was decomposed into several other private
/// methods as a part of this refactoring
///
/// passing in extracted functions as delegates allows
/// for deeper testing with fewer dependencies.
@visibleForTesting
Future<void> fetchQuote(
  bool fromAmountIsZero,
  int fetchingQuoteRetries,
  int fetchingQuoteMaxRetries,
  SwapQuote quote,
  OneInchApiService oneInchApiService,
  FirebaseCrashlytics firebaseCrashlytics,
  void Function() setStateIfFromAmountIsZero,
  void Function() setStateIfFromAmountIsNotZero,
  void Function(SwapQuote quote) setStateOnQuoteResponse,
  SwapRequest Function() generateSwapRequest,
) async { ...

Classes, Mixins, Extends, Implements, and With (Dart 2.7+ also has extension methods!)

Dart has a handful of unique features that interact with the open-closed principle at the class level.

  • With the extends keyword, you can create a typical single-inheritance class hierarchy. This of course allows base types to be open for extension, and they should be closed for modification.
  • The implements keyword allows us to use any type as though it were an interface (Dart does not otherwise have interfaces). A type can implement multiple of these 'interfaces' but does not inherit from them, so this is not multiple inheritance. The interface’s API is closed for modification, but the implementations are open for extension.
  • The with keyword allows a class declaration to include a mixin. Mixins are a structure that allows for code reuse without that code being a part of any class hierarchy. Declare some members inside a mixin, then use the 'with' keyword to apply that mixin to a class. This means the code inside the mixin is closed for modification, the type that uses the mixin is closed for modification, but the act of using the mixin in a class is the extension of that class. One way this can be used is to have a common implementation of a class interface be declared in a mixin that can be shared by other classes that implement that type. Now you can reuse that code without requiring those implementations to extend a parent type to do so. That way those implementations are free to have their own separate hierarchies or none at all. Another way to use mixins is to declare one or only a few members. This makes for highly portable code reuse. You can also type-constrain the classes that are allowed to use the mixin making them work much like extension methods. The important distinction is that mixin methods are instance methods and the mixin has to be declared on the type for it to work. Extensions methods are syntactical sugar for static methods and the type they extend doesn't need to know about them.
  • Speaking of Extension Methods: Dart 2.7+ gives you the option to use extension methods. Extension methods are a great way to add static helpers that can be called directly on a type as if they were instance methods of that type. They are easy to use, easy to write, and perhaps most importantly, they can be declared in a namespace close to the code that is using the helper so that the type API isn't cluttered with odd one-off functions from other namespaces that you don't care about. Types to which extension methods are attached are still closed for modification, but open to extension via extension method, even when that type is in another package!

In Dart, 'substitutability' is accomplished in a couple of ways. One is through extending types (child types will substitute for their supertype). Another is through implementation (a type that implements another type is required to fully implement its API). Mixins can indirectly help an implementation implement another type. Generic types and function type definitions are a couple of places where the contravariance and covariance of types can be leveraged for even more fluid substitution. When tactics such as these are employed, code can be reused across a broader set of use-cases. You don't have to repeat yourself just because the types being used have changed. For example, the generic type 'List' or the delegate 'num Function(num, num)' allow for multiple types to be used while still honoring enough type constraint for meaningful, type-safe, work to be done.

Dart does not have interfaces. When you use the 'implements' keyword you are forced to implement a type in its entirety. These would seem to prevent us from embracing this principle at all. That's where abstract members come in. Types declared with abstract members can be implemented with the 'implements' keyword and as a result, behave exactly like a true interface would. See the code example below for how this plays out with various type structures in dart:

// both mixins and abstract classes can declare abstract members
// however, declaring a mixin that only has abstract members seems
// counter to its intended purpose.  Therefore it is prefered to use
// the abstract class for this purpose.  Types should be decomposed
// to the level where abstract declarations don't need to live in the
// same type as concrete declarations, so keep the abstract ones in
// classes and the concrete ones in mixins.

// don't do this
mixin IDoStuffMixin {
  void doStuff();
}

// do this
abstract class IDoThings {
  void doThings();
}

// a class that implements a type with abstract members must override
// its abstract members

// do this
class Impl implements IDoStuffMixin, IDoThings {
  @override
  void doStuff() {
    print("I implement IDoStuffMixin.");
  }

  @override
  void doThings() {
    print("I implement IDoThings.");
  }
}

// likewise, a class that uses a type with an abstract member as a mixin
// must override the abstract members
// however, this has more limited use, since types that use extends or with
// cannot themselves be used as mixins

// don't do this
class Mix with IDoStuffMixin, IDoThings {
  @override
  void doStuff() {
    print("I mixin IDoStuffMixin.");
  }

  @override
  void doThings() {
    print("I mixin IDoThings.");
  }
}

// a type can implement other types by using any type that already implements
// those types as a mixin

// now we're cookin' with gas!
class CompositionType with Impl implements IDoStuffMixin, IDoThings {
  void doThingsAndStuff() {
    doThings();
    doStuff();
  }
}

void main() {
  CompositionType().doThingsAndStuff();
}

Put another way: depend on abstractions, not implementation details. Don't confuse the use of Dependency Injection as the implicit application of this principle. If dependencies are instantiated INSIDE a type, then there is NO dependency inversion. Even if instantiation takes place via an intermediary such as a factory or DI framework, doing so inside the implementation means that the implementation is now tightly coupled to the implementation details of at least the factory or DI framework itself, if not the concrete implementation of the type being instantiated as well, for example: 'myDIEngine.get()'. This example would be marginally better if the type used in 'get()' was an abstract type (such as an abstract class being used as an interface) but even then, you still have a dependency on a particular DI or factory pattern living inside your implementation. So what should you do instead? Behold:

/// clean abstraction
abstract class IAbstractType {
  void doThings();
}

/// dirty implementation
class ConcreteTypeA implements IAbstractType {
  @override
  void doThings() {
    print("This is how ConcreteTypeA does things.");
  }
}

/// dirty implementation
class ConcreteTypeB implements IAbstractType {
  @override
  void doThings() {
    print("This is how ConcreteTypeB does things.");
  }
}

/// I only expose the abstraction!
class SomeDIOrFactory {
  static final Map<String, IAbstractType> implementations =
      <String, IAbstractType>{};
}

/// Absolutely nothing inside here knows about what methods are used
/// for instantiation and certianly nothing about the concrete types
/// being instantiated or what they do
class DependencyInversion {
  IAbstractType? _abstractType;

  DependencyInversion({IAbstractType? abstractType}) {
    _abstractType = abstractType;
  }

  void doingThingsWithAbstractStuff() {
    _abstractType?.doThings();
  }
}

void main() {
	// here at the top level of code is where 'the sausage is made'
  SomeDIOrFactory.implementations["ImplementationA"] = ConcreteTypeA();
  SomeDIOrFactory.implementations["ImplementationB"] = ConcreteTypeB();

	// at the point in the code where we are extracting implementations
	// and instantiating types with dependencies, we only see the
  // packaging and not the contents
	var iDontKnowHow = DependencyInversion(
      abstractType: SomeDIOrFactory.implementations["ImplementationA"]);
  var iDontCareHow = DependencyInversion(
      abstractType: SomeDIOrFactory.implementations["ImplementationB"]);

	// here we are using the code to do things in a more declarative
  // way - there is no knowledge of the details here at all
	iDontKnowHow.doingThingsWithAbstractStuff();
  iDontCareHow.doingThingsWithAbstractStuff();
}

Why? Because the alternative is creating links between concrete dependencies within each class to each other class on which it depends, and likely doing the same in those classes. Soon you've created a dependency network of classes, some of those dependencies being cyclical (which might even confound your DI engine!).

  • First of all, you won't be able to test anything without dealing with a cascade of dependencies, all of which add weight and reduce determinism both of which should be trending in the other direction.
  • Second, you will find you are less and less capable of making changes to any class without having to also make changes in one or more other classes. This is the definition of technical debt and it will absolutely cripple your velocity.

But Dart and Flutter Say to Use final fields and constant constructors!

Effective Dart - Constant Constructors

Effective Dart - Final Fields

Yes, it's true, and there are performance and stability benefits to both of those things. However, those benefits are a distant second to clean, maintainable, testable code. Also, this is strictly for dependencies. Continue to use final for other fields! Finally, const constructors are preferred where they make sense if it doesn't work for a particular class, it doesn't work. If there is a particular class where it needs to work, like a Widget class, then abstract business logic with external dependencies away in another class.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment