concept and thoughts

The brief goal of the article is to make data flows simpler, more testable and manageable with a DTO and Runtime Model structure.

This article is a set thoughts and expression of experience my current view of this problem is as a combination of experience from working on projects and maybe reinventing the wheel 🙂 But, at the same time, I would like to share these thoughts – and hopefully inspire and look at data structures.

The concept uses some functionality Entities described Robert C. Martin (Uncle Bob) V Clean ArchitectureAlso Model-Driven engineering along with the concept immutability.

This article:
– divided into sections theories And applications, so that the article can be understood by developers who do not know the language used in the examples (Dart).
mainly focuses on client-side (frontend, app, server‑side rendering) developers, but I think it may be of interest to other developers..
– used for examples abstract financial application and the Dart language.

Theoretical principles and concepts as a basis

  1. Separation of Concerns:

    Let's define two types:

    Data Transfer Objects (DTOs)) responsible for:

    — an exact description of the data structure of the data sources (for example API)
    – serialization (converting data into a format that is easy to save or transmit) and deserialization (convert back) data.
    — transfer of data between parts of the system.

    Runtime Models: always created from DTO

    – may include calculated (computed – calculated during program execution (runtime)) parameters and additional fields
    — define a data structure that treats business logic as static data, specially designed for a specific part of the application or UI elements.

  2. Immutability (impossibility of changing the state of an object after its creation):

    Both types of models – DTO and Runtime Model must be immutable.
    This theory can be controversial due to edge cases (for example, it can lead to increased memory usage – since there will literally be more objects), but if you use it, it can lead to to more predictable and understandable code.

  3. Type Safety:

    In DTO – you can add additional converters and rules for exactly how DTO fields should be serialized/deserialized. This approach helps handle potential errors in development rather than in production (for example, a boolean value can be represented as “1”, 1 (as an integer), “TRUE”, or language-native true). Personally, I prefer to treat any serialization with Murphy's Law – everything that can go wrong (with values) will go wrong.

    In Runtime Model you can wrap, create specific fields of calculated or static data to support strong typing, for example, for ID (in Dart, you can use extension types for ID (for example, ExpenseId example below)).

  4. Flexibility:

    DTO – the field name in REST has changed, the protocol in gRPC has changed – all this should be displayed there. DTOs should be flexible, but only for customizing serialization/deserialization, nothing more.

    Runtime Models designed to be more flexible than DTOs (think of them as an Entity in Clean Architecture, but without the business logic). They can include additional fields or calculated properties not found in the DTO, allowing them to be tailored to the specific needs of a particular part of the application or even broken into completely different models.

    For exampleif you feel that your business logic needs two different models filled from the DTO – separate them, you need an enum – add it. At the same time, it is important to do what makes sense – for example, if the Runtime Model is the same as the DTO, there is no point in creating it just because “the theory says so.” In my opinion, sometimes it is better to redefine a theory if it is inconvenient and not adaptable for a particular project.

  5. Testability: Since all data is immutable, write tests. Separate data parsing tests for DTO and business logic in the Runtime Model.

  6. Data conversion: use Factory methods (methods that decide which class to instantiate) to create RuntimeModel instances (eg Expense.fromDto, Expense.toDto in the examples below), handling the conversion from DTO to Runtime Model. This will create a one-way or two-way (if necessary) data flow. I've enjoyed using this in games for various saves etc – i.e. when you have complex data structures described as Runtime Model (states, etc) but very complex to save and load.

  7. Global and Local (Global and local): I often noticed that Sometimes The principle in which all models are created common to the entire application does not work. So if you want isolate part applications in shared library (to share between applications or business logic) or exit to Open Sourceit will be very difficult to refactor the code. That's why Sometimes Applying a two-tier architecture to a business domain (even if the domain is just a UI element) can be much more efficient, but at the same time it very often depends on the application architecture and business goals.

To make it as abstract and concise as possible, let's rethink the concept as Layers:

Outer layer (External Layer – DTO): Responsible for exactly matching the structure of data sources (for example, API responses (not just network calls, but any API that needs to be isolated)).

Inner layer (Internal Layer – Runtime Model): adapted to the specific needs of any part of the application, including calculated properties and business logic.

Transform Layer (other names: transformers, mapping, adapters): handles the conversion of an outer layer to an inner layer and vice versa.


Application of theory in practice

Imagine that you are developing a financial application in Dart that tracks expenses and investments. You need to process data from various sources and present it in an easy-to-use form. Let's try to apply the two-tier architecture described above to it.

Note: In a real Dart app you would use freezed, json_serializable or built_value for generation, but for the sake of simplicity, for all the examples below I'll just use pure Dart.

Defining a Data Transfer Object (DTO)

Since DTOs are data carriers, we use them to convert json-like structures.

In our finance application, a simple ExpenseDTO might look like this:

class ExpenseDTO {
  final String id;
  final String description;
  final double amount;
  final String date; 

  const ExpenseDTO({
    required this.id,
    required this.description,
    required this.amount,
    required this.date,
  });

  factory ExpenseDTO.fromJson(Map<String, dynamic> json) {
    return ExpenseDTO(
      id: json['id'],
      description: json['description'],
      amount: json['amount'],
      date: json['date'],
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'description': description,
      'amount': amount,
      'date': date,
    };
  }
}

Defining the Runtime Model

Continuing with the example of a financial application, the Expense Runtime Model might look like this (I’ll omit toDTO methodbut you can add it if necessary):

extension type ExpenseId(String value){   
  factory ExpenseId.fromJson(final value) => ExpenseId(value);
  String toJson() => value; 
}

class Expense {
  final ExpenseId id;
  final String description;
  final double amount;
  final DateTime date;

  const Expense({
    required this.id,
    required this.description,
    required this.amount,
    required this.date,
  });

  factory Expense.fromDTO(ExpenseDTO dto) {
    return Expense(
      id: ExpenseId.fromJson(dto.id),
      description: dto.description,
      amount: dto.amount,
      date: DateTime.parse(dto.date),
    );
  }

  String get formattedAmount => '\$${amount.toStringAsFixed(2)}';
  bool get isRecentExpense => DateTime.now().difference(date).inDays < 7;
}

Thus, we use Runtime Models as clay, adapting them to business requirements.

Let's assemble the models:

void main() {
  // Simulating data from an API
  final jsonData = {
    'id': '123',
    'description': 'Groceries',
    'amount': 50.99,
    'date': '2023-05-15',
  };

  // Create a DTO from the JSON data
  final expenseDTO = ExpenseDTO.fromJson(jsonData);

  // Convert DTO to runtime model
  final expense = Expense.fromDTO(expenseDTO);

  // Use the runtime model in your app
  print('Recent expense: ${expense.formattedAmount}');
  print('Is recent? ${expense.isRecentExpense}');
}

Let's break off a piece

Now let's imagine that the API has a string address field, but in the application we need more address fields.

Note: in a real application, you'll probably use some kind of parsing API.

Let's apply the field first to ExpenseDTO:


class ExpenseDTO {
  // …. rest of fields
  final String address; // <— added line

  const ExpenseDTO({
    //…. rest of fields
    required this.address, // <— added line
  });

  factory ExpenseDTO.fromJson(Map<String, dynamic> json) {
    return ExpenseDTO(
      //…. rest of fields
      address: json[‘address’], // <— added line
    );
  }

  Map<String, dynamic> toJson() {
    return {
      //…. rest of fields
      ‘address’: address, // <— added line
    };
  }
}

Now let's think about models

Since we need a more complex model AddressDTOlet’s create one (for simplicity, let’s imagine that we don’t need a Runtime Model):

class AddressDTO {
  final String region;
  final String country;
  final String rawAddress;
  final String houseNumber;

  const AddressDTO({
    required this.region,
    required this.country,
    required this.rawAddress,
    required this.houseNumber,
  });

  factory AddressDTO.fromJson(String json) {
    final (:region, :country, :houseNumber) = parseAddressFromString(json);
    return AddressDTO(
      country: region,
      region: country,
      houseNumber: houseNumber,
      rawAddress: json,
    );
  }
  ({String region, String country, String houseNumber}) parseAddressFromString(
      String json) {
    /// will omit implementation for simplicity
  }
}

Now we can add the model to Expense if needed:

class Expense {
  //…. rest of fields
  final AddressDTO address 

  const Expense({
    //…. rest of fields
    required this.address,
  });

  factory Expense.fromDTO(ExpenseDTO dto) {
    return Expense(
      //… rest of fields
       address: AddressDTO.from(dto.address)
     );
  }
}

Or use separately:

void main() {
  // Simulating data from an API
  final jsonData = {
    'id': '123',
    'description': 'Groceries',
    'amount': 50.99,
    'date': '2023-05-15',
    'address': 'USA, Mary Ave, Sunnyvale, CA 94085'
  };
  final addressDTO = AddressDTO.from(jsonData.address);

  // Create a DTO from the JSON data
  final expenseDTO = ExpenseDTO.fromJson(jsonData);

  // Convert DTO to runtime model
  final expense = Expense.fromDTO(expenseDTO);

  // Use the runtime model in your app
  print('Recent expense: ${expense.formattedAmount}');
  print('Is recent? ${expense.isRecentExpense}');
}

At the same time, if we need to add new fields to AddressDTO – we can create Address Runtime Model.

final addressDTO = AddressDTO.from(jsonData.address);
final address = Address.fromDTO(addressDTO);

Applying to UI

Having considered the usual data flow, let's move on to the UI. A few years ago, I was confused about working with the UI layer because the theoretical examples usually dealt with business data rather than the UI/UX data that defines the appearance of a UI element. So let's start with this 🙂

Problem Definition – Styled Button Example

Let's imagine a simple button. Needs to be developed 15–20 visually different style optionswhich will be reused in the application. For simplicity, we will omit the semantics and logic of feedback. What can be done?

Note: since my attitude is subjective, I will describe my thoughts more from the point of view of a developer than a designer.

First option create several buttons for each style (with variations), inspired by Material You – for example, Outlined, Filled, etc.

WITH developer's point of viewthe use of a well-known naming guide makes it easier to switch and reuse components between projects + reduces onboarding time in the team + common logic + it is easier for designers to iterate while maintaining synchronization with developers.

At the same time, it ties the design to specific, non-project and non-business guidelines.

Second option create stylized variations of one button. Let's call this Styled Decorations (the name is conditional: from the point of view of the designer and developer, this button in real life will most likely be divided into different components, but for the sake of the example we will skip this hierarchy).

Of course, there are other options, but we will focus on these two, since this is not the main topic of the article.

Flutter implementation

I will use Flutter as an example of implementing the Styled Decorators idea.

Since we need to define the style (and actually the settings of the button), we can represent it as DTO or Runtime Model. To make the example more interesting, let’s agree that StyledButtonDecoration will Runtime Model. We use backgroundColor, border and nothing else as class fields to emphasize how exactly they can be used.

class StyledButtonDecoration {
  final Color backgroundColor;
  final Border? border;
  const StyledButtonDecoration({
    this.backgroundColor = Colors.white,
    this.border,
  });
  factory StyledButtonDecoration.copyWith({
      Color? backgroundColor,
      Border? border
  })=> StyledButtonDecoration(
    backgroundColor: backgroundColor ?? this.backgroundColor,
    border: border ?? this.border,
  );
  bool get hasBorder => border != null;
}

Since we agreed that this is Runtime Modeladd for example getter hasBorder.

Now we have two options for initializing the model:
1. You can define factories or static variables for the desired styles.

class StyledButtonDecoration {
  // ... rest of decoration
  static const _baseOutlined = StyledButtonDecoration(
    color: Colors.transparent,
    border: Border.all(),
  );
  static const primaryOutlined = _baseOutlined.copyWith(
    border: Border.all(color: Colors.red), 
  );
  static const primaryFilled= StyledButtonDecoration(
    color: Colors.red,
  );
  // ... or 
  static const _outlinedBase = StyledButtonDecoration(
    color: Colors.transparent,
    border: Border.all(),
  );
  static const outlinedPrimary = _outlinedBase.copyWith(
    border: Border.all(color: Colors.red),
  );
  static const filledPrimary= StyledButtonDecoration(
    color: Colors.red,
  );
}

The difference in the name is semantics and design choice – that is, how it will be convenient for the Developer and Designer to use. By defining a variable inside a class, the Developer (and AI) can use code completion in the IDE without having to remember the names, e.g. StyledButtonDecoration.primaryOutlined and modify at the point of application if necessary using copyWith.

  1. Can be filled in styles via constructor. This is an extreme case, but since we agreed to treat the model as a Runtime Model, we can safely create a DTO. From my little experience, this method was useful to me when creating, for example, a game level editor and admin panels.

/// Styles example raw data
const buttonStyles = [
  {
    'backgroundColor': 'FFF',
    'borderColor': 'FFF',
    //...etc
  },
];

class StyledButtonDecorationDTO {
  /// We can parse it to Color if needed, or keep it String,
  /// to have access to original value - it would depend on
  /// business task
  final Color backgroundColor;
  final Color borderColor;
  /// ... rest of parameters which can be used for Border
  const StyledButtonDecorationDTO({
    required this.backgroundColor,
    required this.borderColor,
  });
  factory StyledButtonDecorationDTO.fromJson(final Map<String, dynamic> map){
    return StyledButtonDecorationDTO(
      backgroundColor: parseColorFromHex(map['backgroundColor']),
      borderColor: parseColorFromHex(map['borderColor']),
    );
  }
  Map<String, dynamic> toJson() => {
    'backgroundColor': backgroundColor,
    'borderColor': borderColor,
  };
}

Now let's add fromDto method for StyledButtonDecoration:

class StyledButtonDecoration { 
  ///... rest of class
  factory StyledButtonDecoration.fromDTO(StyledButtonDecorationDTO dto)=> 
    StyledButtonDecoration(
      backgroundColor: dto.backgroundColor,
      border: Border.all(StyledButtonDecoration.borderColor),
    );
}

Thus, we will get the following data flow:

final stylesDtos = styles.map(StyledButtonDecorationDTO.fromJson);
final styles = stylesDtos.map(StyledButtonDecoration.fromDTO);

This gives us dynamic styles, which may not be very useful if used directly (via indexes), but can be very useful if you need, for example, to give the application user more flexibility in UI settings.

The important thing is that while such styles may be overkill for most applications, such logic can be easily represented in a game or application (like Figma).

Note: This approach can be easily adapted for React, Vue, Kotlin Compose and other frameworks, or used without a framework at all – as I think it is more of a theory than an implementation.

Conclusion

Finding the right balance between creating too much code, unnecessary models and at the same time using them as patterns using the right code generation tools (for example in Dart you would use freezed with json_serializable or built_value) is a difficult task, and in my opinion In my opinion, this should depend entirely on a combination of business and developer goals.

For a single application, you will only generate DTOs, and that will be quite normal. For another, you can get very complex Runtime Model structures with complex conversion to/from DTOs.

For me personally, this journey taught me that it is important to treat the developer as a user in the same way we treat the users of the application (even if the developer is an AI)…

Hope you find this concept useful 🙂

Thanks for your time.

Anton

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *