In what way should we map a proto message with a oneof to a dataclass with clean code

Viewed 50

We are working on a flutter application and we use the grpc protocol with proto for our datasource. Some of our messages (particularly responses) have oneof's. I was somewhat confused how we could map this to my own dataclass while still having clean code.

So as an example we have the following proto message

message ComponentProperties {
    oneof properties{
        Sprite sprite = 1;
        Text text = 2;
        Music music = 3;
        SoundEffect soundEffect = 4;
        Spritesheet spritesheet = 5;
    }
}

The idea is that for one of the messages in the oneof we would draw some ui.

The model that we found would fit very naturally is below.

class ComponentProperties {
  final Sprite? sprite;
  final Music? music;
  final SoundEffect? soundEffect;
  final Spritesheet? spritesheet;
  final Text? text;

  bool get hasSprite => sprite != null;
  bool get hasMusic => music != null;
  bool get hasSoundEffect => soundEffect != null;
  bool get hasSpritesheet => spritesheet != null;
  bool get hasText => text != null;

  const ComponentProperties({
    this.sprite,
    this.music,
    this.soundEffect,
    this.spritesheet,
    this.text,
  });

  ComponentProperties copyWith({
    Sprite? sprite,
    Music? music,
    SoundEffect? soundEffect,
    Spritesheet? spritesheet,
    Text? text,
  }) {
    return ComponentProperties(
      sprite: sprite ?? this.sprite,
      music: music ?? this.music,
      soundEffect: soundEffect ?? this.soundEffect,
      spritesheet: spritesheet ?? this.spritesheet,
      text: text ?? this.text,
    );
  }

  Map<String, dynamic> toMap() {
    final map = <String, dynamic>{
      'sprite': sprite?.toMap(),
      'music': music?.toMap(),
      'soundEffect': soundEffect?.toMap(),
      'spritesheet': spritesheet?.toMap(),
      'text': text?.toMap(),
    };
    map.removeWhere((key, value) => value == null);
    return map;
  }

  factory ComponentProperties.fromMap(Map<String, dynamic> map) {
    return ComponentProperties(
      sprite: map['sprite'] != null
          ? Sprite.fromMap((map["sprite"] ?? Map<String, dynamic>.from({})) as Map<String, dynamic>)
          : null,
      music: map['music'] != null
          ? Music.fromMap((map["music"] ?? Map<String, dynamic>.from({})) as Map<String, dynamic>)
          : null,
      soundEffect: map['soundEffect'] != null
          ? SoundEffect.fromMap(
              (map["soundEffect"] ?? Map<String, dynamic>.from({})) as Map<String, dynamic>)
          : null,
      spritesheet: map['spritesheet'] != null
          ? Spritesheet.fromMap(
              (map["spritesheet"] ?? Map<String, dynamic>.from({})) as Map<String, dynamic>)
          : null,
      text: map['text'] != null
          ? Text.fromMap((map["text"] ?? Map<String, dynamic>.from({})) as Map<String, dynamic>)
          : null,
    );
  }

  String toJson() => json.encode(toMap());

  factory ComponentProperties.fromJson(String source) =>
      ComponentProperties.fromMap(json.decode(source) as Map<String, dynamic>);

  proto.ComponentProperties toProto() =>
      proto.ComponentProperties.create()..mergeFromProto3Json(toMap());

  factory ComponentProperties.fromProto(proto.ComponentProperties source) =>
      ComponentProperties.fromMap(source.toProto3Json() as Map<String, dynamic>);

  ComponentProperties merge(ComponentProperties other) =>
      ComponentProperties.fromMap(mergeMaps(toMap(), other.toMap()));

  @override
  String toString() {
    return 'ComponentProperties(sprite: $sprite, music: $music, soundEffect: $soundEffect, spritesheet: $spritesheet, text: $text)';
  }

  @override
  bool operator ==(covariant ComponentProperties other) {
    if (identical(this, other)) return true;

    return other.sprite == sprite &&
        other.music == music &&
        other.soundEffect == soundEffect &&
        other.spritesheet == spritesheet &&
        other.text == text;
  }

  @override
  int get hashCode {
    return sprite.hashCode ^
        music.hashCode ^
        soundEffect.hashCode ^
        spritesheet.hashCode ^
        text.hashCode;
  }
}

But this would lead to writing switch statements where we want to draw UI.

We could also make an interface like so

abstract class ComponentProperties {
  Map<String, dynamic> toMap();
  String toJson();
  factory ComponentProperties.fromProtoMap(
    Map<String, dynamic> map,
  ) {
    final entry = map.entries.first;
    final entryMap = Map<String, dynamic>.fromEntries([
      entry,
    ]);
    switch (entry.key) {
      case Sprite.name:
        return Sprite.fromProtoMap(entryMap);
      case Music.name:
        return Music.fromProtoMap(entryMap);
      case SoundEffect.name:
        return SoundEffect.fromProtoMap(entryMap);
      case Spritesheet.name:
        return Spritesheet.fromProtoMap(entryMap);
      case Text.name:
        return Text.fromProtoMap(entryMap);
      default:
        throw Exception('component with type ${map['type']} unknown');
    }
  }
  Map<String, dynamic> toProtoMap();
}

Which would allow us to have a factory that creates the resource one time. And use polymorphism with something like a draw method to create a widget. So a class named Sprite for example would implement this interface.

Because of how protobuf works it will give you an object with the key being the one from the oneof. For example

{
  sprite: {
    ...
  }
}

And then there are some disadvantages.

We would need something like map.entries.first to get the actual resource that was recieved because we don't know what the one in oneof is. But only when we map from and to proto.

You can't override copyWith because each resource has different properties, so you can't call properties.copyWith().

We would need different mapping for proto specifically because the map contains an object that has a key that represents the type. This usually leads to someone using toMap when generating code where toProtoMap should be used and causes bugs.

And we would use polymorphism to draw the widget. This would mean that we would implement draw methods in our dataclasses in order to render anything to the screen. I haven't really seen other Flutter projects do this. The ones I have seen usually only have import from the data layer into the presentation layer and not the other way around. It would mean we need to import widgets in our dataclasses.

This also means we couldn't have these dataclasses in an all dart package that is a dependency of multiple flutter packages. We would need to import material so we would need to make it a flutter package. And we would need to have the presentation logic in this dependency package. Even though we want to render different UI in the flutter packages that this package is a dependency of. We also wouldn't be able to for example override the draw method in those flutter packages because the dataclass is in the dependency package. So we would we would have to maintain the same dataclasses in each flutter project so we can implement the draw method there. Which also causes a lot of maintenance.

The proto code generated also has the fields as nullable. Which makes mapping more natural.

So the first approach seems to be more in line and natural with the oneof and flutter architecture. But it goes against clean code. And will cause switch statements each time we want to draw something.

So my question is could we approach this so we have clean code that also still feels natural with protobuf, allows you to copy the dataclass easily and is in line with flutter architecture?

Because it almost feels like we have to choose between these two options and they both have their disadvantages. I would say we would only render a widget in one place for this dataclass. But we have to copy the dataclass many times. And there is also code generated which has already caused multiple errors because it defaults to toMap. Which makes the first model seem much more attractive.

But in the future we might want to render widgets in different places which would make the switch statements unmaintainable.

0 Answers
Related