Model

Model is a data structure. It has to be IMMUTABLE to be considered as a model because they are used across objects.

For comparison, as usual in Flutter, models extend from Equatable of equatable. This is for us to conveniently check the equality of all attributes of models, resulting in the equality of models. It is very correct but not effective in practice as our experience shows.

Take the Bloc pattern as an example because it requires the usage of Equatable a lot. As its documentation states, the State should extend from Equatable. For every about-to-emit State, Blocs check the equality with the current State. If they are equal, Blocs will not emit the State to save resources. It makes sense. But in reality, we observe that the chance of two executive states to be equal is very low, which means it is better optimization to let the States be emitted and UI re-render by ignoring the comparison, especially with states that contain collections of models. The Equatable loops for each model in the collection of models and each attribute of those models to check equality, which may cause bottlenecks if the collections are large.

Even if the comparison is needed, we suggest a different approach - a unique identity.

Imagine we have a car factory, in the production line, we produce a lot of cars with identical configurations: color, engine type, seats, etc. But no matter how very similar they are, they are unique by one thing - VIN. Now we define a model class of cars:

class Car {
    final String engine;
    final String make;
    final String color;
    final String vin;
    ...
}

With Equatable, all attributes are brought into check. As explained earlier, it is not necessary because all we need to compare is Car.vin. We provide a convenient class with a built-in attribute to define a unique identity for a model: key.

Model

import 'package:paper/paper.dart';

class Car extends Model {
    Car({
        required this.engine,
        required this.make,
        required this.color,
        required this.vin,
    });

    
    String get key => vin;

    final String engine;
    final String make;
    final String color;
    final String vin;
}

void main(){
    /// The car will be unique based on its key (or its vin).
    /// car.key = 'A1B2C3D4';
    final car = Car(
        engine: 'V6',
        make: 'Toyota',
        color: 'blue',
        vin: 'A1B2C3D4',
    );
}

or

import 'package:paper/paper.dart';

class Car extends Model {
    Car({
        required this.engine,
        required this.make,
        required this.color,
        required this.key,
    });

    
    final String key;

    final String engine;
    final String make;
    final String color;
}

void main(){
    /// The car will be unique based on its key (vin is replaced).
    /// car.key = 'A1B2C3D4';
    final car = Car(
        engine: 'V6',
        make: 'Toyota',
        color: 'blue',
        key: 'A1B2C3D4',
    );
}

or you can define the key with your own project-generated unique value; for example by using uuid

How to define the Model.key is based on the nature of the data, the project, and the context of features, that you should analyze the pros and cons.

Collection

Using collection types is very common, especially List and Map, but as we experienced, we believe they have been usually used in the wrong way. Developers treat them directly as Models like: final cars = List<Car>, which violates the immutability of a Model because they are mutable by List.insert, List.remove, etc. The correct approach should be: final cars = UnmodifiableListView<Car>([]). Besides, as a model, it also needs to define its key, so we provide special types of Models:

  • ListModel

  • MapModel

But how to define the key of a collection model? By default, the key would be a String combination of all elements' keys separated by "&".

void main(){
    /// The key of this model is 'car-1&car-2&car-3'.
    final carsList = ListModel([
        Car(vin: 'car-1'),
        Car(vin: 'car-2'),
        Car(vin: 'car-3'),
    ]);

    /// The key of this model is '1&2&3'.
    final carsMap = MapModel({
        1: Car(vin: 'car-1'),
        2: Car(vin: 'car-2'),
        3: Car(vin: 'car-3'),
    });
}

You can also define the key by your own. A list of items can be distinguished by its filter criteria.

void main(){
    final carsList = ListModel(
        [
            Car(vin: 'car-1'),
            Car(vin: 'car-2'),
            Car(vin: 'car-3'),
        ],
        keyGen: () => 'sort=latest&condition=used&type=sedan'
    );
}

Json

As conventional, we usually define Json in the following format:

class Json {
    Json({
        required this.engine,
        required this.make,
        required this.color,
        required this.vin,
    });

    final String engine;
    final String make;
    final String color;
    final String vin;

    factory Json.fromJson(Map<String, dynamic> json){
        ...
    }

    Map<String, dynamic> toJson() {
        ...
    }
}

We don't recommend this approach. The problem lies within fromJson. There are many potential issues that happen during the parsing from json (wrong type, nullable value, etc.), which throw exceptions. We can handle them with try-catch statements but it makes the constructor handle too many operations. In our opinion, constructors should not throw out any exceptions and should always successfully initialize instances. Any unexpected errors should be handled in specific operations for better maintenance.

We recommend that we should turn a Json into a Dart Object first and then "convert" it into Models. Please refer to Data Fetching. Therefore, we also provide classes for Json Models.

We have a sample json:

/// sample.json
{
  "id": "id",
  "name": "Betta",
  "age": 33,
  "address": {
    "city": "Ho Chi Minh",
    "country": "Vietnam"
  },
  "hobbies": ["coding", "reading", "traveling"]
}

We will have a representative for this json:

class Sample extends MapJsonModel {
  Sample(super.json);

  
  Object get key => id;

  String? id() {
    return tryString('id', getter: id);
  }

  String? name() {
    return tryString('name', getter: name);
  }

  num? age() {
    return tryNum('age', getter: age);
  }

  SampleAddress? address() {
    return tryMapObject<SampleAddress>(
      'address',
      (j) => SampleAddress(j),
      getter: address,
    );
  }

  SampleHobbies? hobbies() {
    return tryListObject<SampleHobbies>(
      'hobbies',
      (j) => SampleHobbies(j),
      getter: hobbies,
    );
  }
}

class SampleAddress extends MapJsonModel {
  SampleAddress(super.json);

  String? city() {
    return tryString('city', getter: city);
  }

  String? country() {
    return tryString('country', getter: country);
  }
}

class SampleHobbies extends ListJsonModel {
  SampleHobbies(super.json);
}

Then we get the data:

import 'dart:convert';

void main(){
    try{
        final response = await rootBundle.loadString('sample.json');
        final json = jsonDecode(response) as Map<String, dynamic>;

        final sample = Sample(json);

        /// Now we can convert the json to model needed for the app.

        final id = sample.id();
        final name = sample.name();
        final age = sample.age();

        /// Handle in case the value is unexpectedly null.
        if (id == null || name == null || age == null) {
            throw Exception('Invalid data');
        }

        final person = Person(
            id: id,
            name: name,
            age: age,
        );

    } catch (e){
        /// handle exceptions
    }
}