Краткая цель статьи — сделать потоки данных проще, более тестируемыми и управляемыми с DTO и Runtime Model структурой.
Эта статья — набор мыслей и экспрессии опыта моего текущего видения этой проблемы, как комбинации опыта от работы над проектами и может быть, переизобретение колеса:) Но, в то же время, я хотел бы поделиться этими мыслями — и, надеюсь, вдохновить и посмотреть на структуры данных.
Концепт использует немного функционала Entities, описанных Robert C. Martin (Uncle Bob) в Clean Architecture, также Model‑Driven engineering вместе с концептом immutability.
Эта статья:
— разделена на секцию теории и применения, чтобы статью можно было понять разработчикам не знающим язык используемый в примерах (Dart).
— в основном фокусируется на client‑side (frontend, app, server‑side рендеринг) разработчиках, но думаю что может быть интересна и другим разработчикам..
— для примеров используется абстрактное финансовое приложение и язык Dart.
Теоретические принципы и концепты в качестве основы
-
Separation of Concerns (разделение ответственности):
Определим два типа:
Data Transfer Objects (DTOs) отвественные за:
— точное описание структуры данных источников данных (например, API)
— сериализацию (конвертирование данных в формат, который легко сохранить или передать) и десериализацию (конвертация обратно) данных.
— перенос данных между между частями системы.Runtime Models: всегда создаваемые из DTO
— могут включать вычисляемые (computed — рассчитываемые в ходе выполнения программы (runtime)) параметры и дополнительные поля
— определяют такую структуру данных, которая относится к бизнес логике как к статическим данным, специально разработанных под конкретную часть приложения или UI элементов. -
Immutability (невозможность изменения состояния обьекта после его создания):
Оба типа моделей — DTO и Runtime Model должны быть immutable.
Эта теория может быть противоречивой из‑за крайних случаев (например может вести к повышению использования памяти — так как буквально объектов будет больше), но если вы используете её ‑ она может привести к более предсказуемому и понятному коду. -
Type Safety (безопасность типов):
— В DTO — можно добавить дополнительные преобразователи и правила, как именно поля DTO должны сериализоваться/десериализоваться. Этот подход помогает обрабатывать потенциальные ошибки на этапе разработки, а не в продакшене (например, булево значение может быть представлено как «1», 1 (как целое число), «TRUE» или нативное для языка true). Лично я предпочитаю относиться к любой сериализации с законом Мерфи — всё, что может пойти не так (со значениями), пойдет не так.
— В Runtime Model можно обернуть, создать специфичные поля вычисляемых или статических данных, чтобы поддержать строгую типизацию, например, для ID (в Dart для ID можно использовать типы‑расширения (например, ExpenseId пример ниже)).
-
Гибкость:
— DTO — изменилось имя поля в REST, изменился протокол в gRPC — всё это должно быть отображено там. DTO должны быть гибкими, но только для настройки сериализации/десериализации, не более.
— Runtime Models разработаны более гибкими, чем DTO (можно рассматривать их как Entity в Clean Architecture, но без бизнес‑логики). Они могут включать дополнительные поля или вычисляемые свойства, отсутствующие в DTO, позволяя адаптироваться к специфическим нуждам определенной части приложения или даже разбиваться на совершенно разные модели.
Например, если вы чувствуете, что вашей бизнес‑логике нужны две разные модели, заполняемые из DTO — разделите их, нужен enum — добавьте его. В то же время важно делать то, что имеет смысл — например, если Runtime Model такая же, как DTO, нет смысла создавать её только потому, что «так написано в теории». По моему мнению, иногда лучше переопределить теорию, если она неудобна и не адаптируема для конкретного проекта.
-
Тестируемость: поскольку все данные immutable, пишите тесты. Разделяйте тесты для парсинг данных для DTO и бизнес‑логику в Runtime Model.
-
Преобразование данных: используйте Factory методы (методы, которые решают, какой класс инстанцировать) для создания RuntimeModel instances (например, Expense.fromDto, Expense.toDto в примерах ниже), обрабатывая преобразование из DTO в Runtime Model. Это создаст односторонний или двусторонний (если нужно) поток данных. Мне понравилось применять это в играх для различных сохранений и т. д. — т. е. когда у вас есть сложные структуры данных, описанные как Runtime Model (states, и т. д.), но очень сложные для сохранения и загрузки.
-
Global and Local (Глобальное и локальное): часто замечал, что иногда не работает принцип, при котором все модели создаются общими для всего приложения. Так как, если вы хотите изолировать часть приложения в общую библиотеку (чтобы делиться между приложениями или бизнес‑логикой) или выйти в Open Source, будет очень сложно рефакторить код. Поэтому иногда применение two tier архитектуры к бизнес‑домену (даже если домен — это просто элемент UI) может быть гораздо эффективнее, но в то же время это очень часто зависит от архитектуры приложения и бизнес‑целей.
Чтобы сделать максимально абстрактно и кратко, переосмыслим концепцию как слои (Layers):
Внешний слой (External Layer — DTO): отвечает за точное соответствие структуре источников данных (например, ответы API (не только сетевые вызовы, но любой API, который нужно изолировать)).
Внутренний слой (Internal Layer — Runtime Model): адаптирован под специфические нужды любой части приложения, включая вычисляемые свойства и бизнес‑логику.
Слой преобразования (другие названия: трансформеры, маппинг, адаптеры): обрабатывает преобразование внешнего слоя во внутренний слой и наоборот.
Применение теории на практике
Представьте, что вы разрабатываете финансовое приложение на языке Dart, которое отслеживает расходы и инвестиции. Вам нужно обрабатывать данные из различных источников и представлять их в удобном для пользования виде. Давайте попробуем применить к нему описанную выше two‑tier архитектуру.
Примечание: в реальном приложении на dart для генерации вы будете использовать freezed, json_serializable или built_value, но для простоты, для всех примеров ниже, я буду использовать просто чистый Dart.
Определяя Data Transfer Object (DTO)
Так как DTOs являются переносчиками данных, мы используем их для конвертации json подобных структур.
В нашем финансовом приложении, простой ExpenseDTO может выглядеть так:
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,
};
}
}
Определяя Runtime Model
Продолжая пример финансовго приложения, Expense Runtime Model может выглядеть так (опущу toDTO метод, но при необходимости его можно добавить):
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;
}
Таким образом, мы используем Runtime 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}');
}
Отломим кусок
Теперь представим, что API имеет строковое поле адрес, но в приложении нам нужны больше полей адреса.
Примечание: в реальном приложении, вероятно, вы будете использовать какой‑то API для парсинга.
Давайте применим поле сначала к 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
};
}
}
Теперь подумаем о моделях
Так как нам нужна более сложная модель AddressDTO, создадим такую (для простоты, представим что нам не нужна 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
}
}
Теперь мым можем добавить модель к Expense если нужно:
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)
);
}
}
Или использовать отдельно:
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}');
}
В то же время, если нам нужно будет добавить новые поля в AddressDTO — мы можем создать Address Runtime Model.
final addressDTO = AddressDTO.from(jsonData.address);
final address = Address.fromDTO(addressDTO);
Применяя к UI
Рассмотрев обычный поток данных, перейдем к UI. Несколько лет назад, меня вводила в путаницу работа с UI-слоем, так как примеры в теории обычно касались бизнес-данных, а не UI/UX-данных, определяющих внешний вид элемента UI. Поэтому начнем с этого:)
Определение проблемы— пример Styled Button (стилизованной кнопки)
Представим простую кнопку. Нужно разработать 15–20 визуально разных вариантов стилей, которые будут переиспользоваться в приложении. Для простоты опустим семантику и логику feedback. Что можно сделать?
Примечание: так как мое отношение субъективно, опишу мысли больше с точки зрения разработчика, чем дизайнера.
Первый вариант создать несколько кнопок для каждого стиля (с вариациями), вдохновляясь Material You — например, Outlined, Filled и т. д.
С точки зрения разработчика, использование известного гайда по наименованиям упрощают переключение и переиспользование компонентов между проектами + сокращает время онбординга в команде + общая логика + дизайнерам легче итерировать, сохраняя синхронизацию с разработчиками.
В то же время, это привязывает дизайн к специфическим, непроектным и небизнесовым гайдам.
Второй вариант создать стилизованные вариации одной кнопки. Назовем это Styled Decorations (название условное: с точки зрения дизайнера и разработчика эта кнопка в реальной жизни скорее всего будет разделена на разные компоненты, но для примера мы пропустим эту иерархию).
Конечно, есть и другие варианты, но остановимся на этих двух, так как это не основная тема статьи.
Flutter имплементация
Я буду использовать Flutter как пример имплементации идеи Styled Decorators.
Так как нам нужно опредить стиль (а фактически — настройки кнопки), мы можем представить его как DTO или Runtime Model. Чтобы пример был интереснее договримся что StyledButtonDecoration будет Runtime Model. В качестве полей класса используем backgroundColor, border и ничего больше, чтобы сделать акцент на том, как именно их можно использовать.
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;
}
Так как мы договорились что это Runtime Model, добавим для примера getter hasBorder.
Теперь у нас есть два варианта инициализации модели:
1. Можно определить factories или static переменные для нужных стилей.
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,
);
}
Разница в наименовании — это семантика и дизайнерский выбор — то есть то, как будет удобно использовать Разработчику и Дизайнеру. Определяя переменную внутри класса, Разработчик (и AI) может использовать автодополнение кода в IDE, без необходимости запоминать названия, например StyledButtonDecoration.primaryOutlined
и модифицировать в месте применения при необходимости, используя copyWith
.
-
Можно заполнить стили через конструктор. Это экстремальный случай, но так как мы договорились рассматривать модель как Runtime Model, мы можем безопасно создать DTO. Из моего небольшого опыта — мне этот метод пригодился при создании например редактора уровней игры и админок.
/// 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,
};
}
Теперь добавим fromDto метод для StyledButtonDecoration:
class StyledButtonDecoration {
///... rest of class
factory StyledButtonDecoration.fromDTO(StyledButtonDecorationDTO dto)=>
StyledButtonDecoration(
backgroundColor: dto.backgroundColor,
border: Border.all(StyledButtonDecoration.borderColor),
);
}
Таким образом, у нас получится следующий поток данных (data flow):
final stylesDtos = styles.map(StyledButtonDecorationDTO.fromJson);
final styles = stylesDtos.map(StyledButtonDecoration.fromDTO);
Так мы получили динамические стили, которые, возможно, не очень полезны если использовать их напрямую (через индексы), но могут быть очень полезными, если вам нужно, например, дать пользователю приложения больше гибкости в настройках UI .
Важно, что несмотря на то, что такие стили могут быть излишними для большинства приложений, такую логику можно легко представить в игре или приложении (как Figma).
Примечание: этот подход можно легко адаптировать для React, Vue, Kotlin Compose и других фреймворков или использовать его вообще без фреймворка — так как думаю, что это скорее теория, чем реализация.
Заключение
Найти правильный баланс между созданием слишком большого количества кода, ненужных моделей и в то же время применением их как паттернов, используя правильные инструменты для генерации кода (например, в Dart вы будете использовать freezed с json_serializable или built_value) — сложная задача, и, по моему мнению, это должно полностью зависеть от комбинации бизнес‑целей и целей разработчика.
Для одного приложения вы будете генерировать только DTO, и это будет вполне нормально. Для другого — можно получить очень сложные структуры Runtime Model со сложным преобразованием в/из DTO.
Лично для меня этот путь дал урок, что важно относиться к разработчику как к пользователю так же, как мы относимся к пользователям приложения (даже если разработчик — это AI)...
Надеюсь, что этот концепт окажется полезным для вас :)
Спасибо за ваше время.
Антон
Автор: Arenukvern