DEV Community

Cover image for DuitDataSource: The Data Layer That Quietly Changed Everything in Duit
Nikita Sin
Nikita Sin

Posted on

DuitDataSource: The Data Layer That Quietly Changed Everything in Duit

1000 widget update iterations: about 1.9 seconds with the old attribute-based approach, 114 ms with the newer one.

That gap is the reason this article exists.

In Duit, widget updates used to revolve around attribute classes: parse JSON into Dart objects, create new attribute instances for every update, then merge old and new state to produce the final result. It was structured. It was explicit. In a hot path, especially during animations, it was also too expensive.

Each update meant more parsing, more copying, more object allocation, and more boilerplate than the framework was getting back in value. In the most expensive scenarios, that translated into more GC pressure and slower frame preparation.

The improvement did not come from one clever micro-optimization. It came from changing the role of data in the framework.

This article explains why DuitDataSource is not just a typed wrapper around Map<String, dynamic>, but the runtime layer that now handles parsing, partial updates, memoization, and a large share of the framework's hot-path optimization work.

For a bit of context: Duit is a BDUI framework for Flutter. It lets teams describe UI remotely and turn structured payloads into native Flutter widgets, actions, commands, and runtime behavior on the client.

That solves a very practical set of problems. It helps ship UI changes faster, reduces the amount of client-side release work for presentation-level updates, and gives smaller teams access to a BDUI-style architecture without having to build an entire internal platform from scratch.

The old problem was not "JSON"

The obvious explanation would be: "JSON is dynamic, typed attribute classes are safer, therefore classes are the right abstraction."

That sounds good until you have to live with the consequences:

  • every widget needs a dedicated attribute model
  • every update creates intermediate objects
  • merge logic becomes both repetitive and fragile
  • animations magnify the cost because the same properties are touched again and again

The problem was not that the framework started from Map<String, dynamic>.

The problem was converting that map into too many short-lived objects too early, then paying the price for that decision on every update.

So I went back to the thing I originally tried to avoid: a plain Map<String, dynamic>.

Not raw. Not unstructured. Not "just trust the payload".

Wrapped.

Why DuitDataSource exists

DuitDataSource is an extension type over Map<String, dynamic>:

extension type DuitDataSource(Map<String, dynamic> _json)
    implements Map<String, dynamic> {
  // typed accessors and converters
}
Enter fullscreen mode Exit fullscreen mode

It keeps the cheap and flexible storage model of a map, but exposes strongly typed getters and conversion methods for Flutter and Dart values: colors, durations, alignments, curves, borders, actions, animation settings, command payloads, and many other property types.

So the framework no longer needs a dedicated "attributes class" per widget just to say "this JSON field is a Color, this one is an EdgeInsets, this one is a Duration."

Instead, DuitDataSource becomes a typed access layer over the payload that already exists.

What role it plays in the current project

Today DuitDataSource is not a small helper. It is one of the core runtime abstractions in Duit.

It plays at least four different roles.

1. Boundary between remote JSON and native Flutter types

This is the most visible role.

Transport code receives JSON. Widgets, commands, and actions need Flutter-native values. DuitDataSource is the boundary where that transformation happens.

For example, transport decoding can optionally use DuitDataSource.jsonReviver during jsonDecode:

return jsonDecode(
  utf8.decode(data),
  reviver: DuitDataSource.jsonReviver,
) as Map<String, dynamic>;
Enter fullscreen mode Exit fullscreen mode

Then higher layers wrap the payload with DuitDataSource and read typed values instead of manually parsing maps all over the codebase:

final source = DuitDataSource(command.commandData);

return BottomSheetCommand(
  content: source["content"] ?? const {},
  backgroundColor: source.tryParseColor(key: "backgroundColor"),
  shape: source.shapeBorder(key: "shape"),
  clipBehavior: source.clipBehavior(key: "clipBehavior"),
  onClose: source.getAction("onClose"),
  action: OverlayAction.parse(source.getString(key: "action")),
  // ...
);
Enter fullscreen mode Exit fullscreen mode

The effect is simple and important: parsing logic stops leaking into every command, widget, and adapter.

The framework gets one place where data is interpreted, and the rest of the runtime gets to consume typed values.

2. Update substrate for widget changes and animations

This is where the "it is just a typed map wrapper" explanation stops being enough.

In the current project, DuitDataSource is also the object that updates flow through.

Animated properties are merged directly into the same data source:

mixin AnimatedAttributes on Widget {
  DuitDataSource mergeWithDataSource(
    BuildContext context,
    DuitDataSource dataSource,
  ) {
    final parentId = dataSource.parentBuilderId;
    if (parentId == null) return dataSource;

    final animationContext = DuitAnimationContext.maybeOf(context);
    if (animationContext == null) return dataSource;
    if (animationContext.parentId != parentId) return dataSource;

    final affectedProps = dataSource.affectedProperties;
    if (affectedProps == null || affectedProps.isEmpty) {
      return dataSource;
    }

    final animatedProperties = <String, dynamic>{};

    for (final prop in affectedProps) {
      final value = animationContext.streams[prop]?.value;
      if (value != null) {
        animatedProperties[prop] = value;
      }
    }

    return dataSource..addAll(animatedProperties);
  }
}
Enter fullscreen mode Exit fullscreen mode

DuitDataSource is no longer only the thing that reads properties. It is also the thing that receives partial updates, applies merges, and becomes the mutable substrate for the final widget state.

This is why it matters so much in animation-heavy scenarios. The framework does not need to keep manufacturing replacement attribute objects just to override one or two fields for the next frame.

It can merge the changed values into the current data structure and move on.

3. Memoization layer, not just a parser

This is the role that ended up being the most important.

At first glance, a method like tryParseColor() looks like a parser helper. In reality, it is doing something more valuable: it memoizes the parsed result.

After the first successful conversion, the native object is written back into the underlying map:

Color? tryParseColor({
  String key = FlutterPropertyKeys.color,
  Color? defaultValue,
  Object? target,
  bool warmUp = false,
}) {
  final value = _readProp(key, target, warmUp);

  if (value is Color) return value;
  if (value == null) return defaultValue;

  switch (value) {
    case String():
      return _json[key] = _colorFromHexString(value);
    case List():
      return _json[key] = _colorFromList(value);
    default:
      return defaultValue;
  }
}
Enter fullscreen mode Exit fullscreen mode

The same pattern appears across many accessors: colors, durations, enums, alignments, sizes, actions, and more.

That changes the economics of repeated reads.

The first access pays the parsing cost. The next access often becomes a cheap "already typed, return as is" path. In a static payload, that is nice. In repeated rebuilds and animations, that is a major win.

That is why I would describe the current role of DuitDataSource like this:

It is not primarily a JSON parser. It is a memoizing typed data layer for a remote UI runtime.

That distinction matters because it explains both the API and the performance profile.

4. A deliberate performance lever

Once DuitDataSource became the center of property access, it also became the right place for low-level optimization.

That is where optimizations like these start to pay off:

  • replacing attribute object copying with map merge semantics
  • reusing already parsed native values
  • using lookup tables for enum-like conversions
  • keeping hot functions small enough to be good candidates for @pragma('vm:prefer-inline')
  • using jsonReviver and warm-up dispatch to front-load selected conversions

This matters because DuitDataSource methods are not called once in a while. They are called everywhere:

  • while decoding transport responses
  • while creating commands
  • while building widgets
  • while resolving styles
  • while applying animated updates

If a function in that path becomes 15% faster, it is not an academic improvement. It compounds.

And if a function in that path avoids allocations entirely, the gain is not just raw CPU time. It is also lower GC activity and more predictable runtime behavior.

Why extension types were the right tool

Without extension types, this design would be much less attractive.

Using a wrapper class would introduce more ceremony and potentially more overhead. Using a raw map directly would destroy the ergonomics and safety of the API.

Extension types make it possible to keep the data representation simple while still exposing a rich, typed interface.

That is the real trick behind DuitDataSource.

The representation stays simple, while the API remains typed and the hot path stays cheap.

What this changed for the framework

The benefits go beyond benchmark charts.

Moving the framework around DuitDataSource changed several things at once:

  • less widget-specific boilerplate
  • fewer fragile merge/copy implementations
  • fewer intermediate objects in the update pipeline
  • simpler APIs for commands, custom widgets, and model construction
  • a better foundation for tooling built around Duit

This is also why DuitDataSource matters to the future of the project, not just to one optimization pass.

When the core runtime speaks a consistent typed-map language, other parts of the ecosystem get easier to build. Tooling, editor integrations, debugging helpers, visual builders, and migration utilities all benefit from that simplification.

From that perspective, DuitDataSource is not a utility type.

It is the data contract the runtime now organizes itself around.

The bigger lesson

The interesting part of this story is not "maps are faster than classes."

That would be too shallow, and often false outside the specific workload.

The real lesson is that the best abstraction is the one that matches the runtime behavior you actually need.

In Duit, the hot path was not "create beautiful immutable attribute models." The hot path was:

  1. receive remote payload
  2. read a lot of properties repeatedly
  3. update only a small subset of them frequently
  4. avoid extra allocations while doing it

Once I looked at the problem through that lens, DuitDataSource stopped being an implementation detail and became the right architectural center.

That is its role in the project today.

It is the layer that turns dynamic payloads into typed Flutter data, the object that absorbs partial updates, the cache that avoids reparsing work, and the lever that lets performance work scale across the entire runtime.

Not bad for something that started as "maybe I should just use a map."

Practical takeaway

If you are building a remote UI system, a config-driven runtime, or any framework that repeatedly interprets structured data, do not ask only:

"How do I make this typed?"

Also ask:

"Where will this data live after the first parse?"
"How many times will I read it again?"
"Can the same structure be both the source of truth and the cache?"

In Duit, that set of questions led to DuitDataSource. Once it did, a lot of other pieces started to fall into place.

If you want to dig into the implementation, start here:

Top comments (0)