Records в C# - это, по сути, синтаксический сахар над обычными классами и структурами. Но какой же это вкусный сахар! Если говорить совсем просто - это специальный тип данных, разработанный Microsoft для моделирования неизменяемых объектов, которые представляют данные, а не поведение. Вот простейший пример записи:
| C# | 1
| public record Person(string FirstName, string LastName); |
|
Это всё! Одна строчка кода, и у нас уже есть полноценный тип с конструктором, свойствами, реализованными Equals(), GetHashCode(), ToString() и даже возможностью деконструкции. За кулисами компилятор генерирует значительно больше кода, но нам не приходится его писать вручную. Когда я начал использовать Records, то сразу заметил, что количество кода в проекте значительно уменьшилось. Буквально тысячи строк бойлерплейт-кода ушли в небытие. А чем меньше кода, тем меньше багов - эту простую истину я усвоил еще в начале карьеры.
Microsoft внедрила Records в C# 9 (выпущен в ноябре 2020 года), отвечая на давний запрос сообщества разработчиков. Ключевой мотивацией было стремление упростить создание иммутабельных структур данных, что особенно актуально в мире многопоточного программирования, функциональных подходов и микросервисной архитектуры.
Честно говоря, я считаю, что это нововведение запоздало как минимум на пару лет. В Java аналогичные механизмы появились раньше, а в функциональных языках вроде F# или Scala концепция иммутабельных типов данных существовала изначально. Вспоминаю, как мучался с реализацией Value Objects в проекте банковской системы - пришлось написать целую кучу шаблонного кода, генерировать методы сравнения и хэширования, тестировать все это... А сейчас бы просто использовал Records.
Ключевые отличия Records от обычных классов:
1. Семантика сравнения по значению (value-based equality) вместо сравнения по ссылке.
2. Иммутабельность по умолчанию (при использовании позиционных параметров).
3. Встроенная поддержка "неразрушающего мутирования" через with-выражения.
4. Автоматически сгенерированный метод ToString(), который отображает все свойства.
5. Возможность использовать деконструкцию.
Records сильно вдохновлены функциональным программированием, где иммутабельность - это норма, а не исключение. В F#, например, record types существуют давно, и теперь эта концепция перекочевала в C#.
Что касается влияния Records на компиляцию - мой опыт показывает, что для небольших и средних проектов разница в скорости сборки незаметна. Однако в крупных enterprise-решениях, где тысячи DTO-классов, использование Records может существенно ускорить компиляцию. У меня был проект с более чем 300 DTO-классами, которые мы конвертировали в Records, и время сборки сократилось примерно на 7%. Не революция, конечно, но приятный бонус.
Что интересно, под капотом компилятор преобразует Records в обычные классы со всей необходимой функциональностью. Это значит, что на уровне IL-кода нет никакой магии - всё та же CLR, те же механизмы, просто меньше ручной работы для программиста.
Вот я как-то пытался объяснить сыну-подростку, что такое Records в C#. Сказал ему: "Представь, что ты описываешь своего персонажа в игре. Имя, уровень, навыки... Раньше тебе нужно было бы писать много кода, чтобы правильно сравнивать персонажей, копировать их с изменениями. А с Records ты просто говоришь 'вот мой персонаж', и всё остальное происходит автоматически". Кажется, он понял - по крайней мере, заинтересовался программированием.
Если вы когда-нибудь сталкивались с паттерном Value Object в DDD (Domain-Driven Design), то Records покажутся вам знакомыми. Они идеально подходят для реализации этого паттерна, поскольку имеют встроенное сравнение по значению. Я помню, как раньше приходилось вручную переопределять Equals() и GetHashCode() для каждого Value Object, а потом еще писать тесты, чтобы убедиться, что реализация корректна. С Records эта головная боль ушла.
Отдельно хочу остановиться на концепции неизменяемости (immutability). Многие разработчики, особенно с бэкграундом из ООП, часто недооценивают преимущества неизменяемых объектов:
1. Они потокобезопасны без дополнительной синхронизации,
2. Упрощают кеширование (не нужно беспокоиться об изменении состояния),
3. Делают код предсказуемым (после создания объект не меняется),
4. Позволяют создавать функции без побочных эффектов.
В контексте многопоточного программирования это просто бесценно. В одном из моих предыдущих проектов мы тратили недели на отладку race condition, возникавших из-за изменения общих данных в разных потоках. После перехода на иммутабельную модель большинство проблем просто исчезло.
В C# 10 Microsoft пошла дальше и добавила record struct - синтез Records и структур:
| C# | 1
2
| // C# 10+
public record struct Point(int X, int Y); |
|
Это дало нам лучшее из обоих миров: семантику сравнения по значению и преимущества структур (выделение в стеке, отсутствие накладных расходов на управление памятью).
Что меня особенно впечатлило в Records - это элегантный механизм "неразрушающего мутирования" с помощью with-выражений:
| C# | 1
2
| var john = new Person("John", "Doe");
var jane = john with { FirstName = "Jane" }; // Создание нового объекта на основе существующего |
|
Попробуйте реализовать что-то подобное с обычными классами без большого количества бойлерплейт-кода!
Кстати, о синтаксисе. Records можно определять несколькими способами:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| // Позиционный синтаксис (самый краткий)
public record Person(string FirstName, string LastName);
// Синтаксис, похожий на класс (больше контроля)
public record Person
{
public string FirstName { get; init; }
public string LastName { get; init; }
public Person(string firstName, string lastName)
{
FirstName = firstName;
LastName = lastName;
}
} |
|
Второй вариант даёт больше гибкости, но и требует больше кода. Я обычно использую позиционный синтаксис для простых DTO и синтаксис, похожий на класс, когда нужна более сложная логика инициализации.
Интересно, что Records повлияли и на архитектурный стиль C#-приложений. Я замечаю, что после их появления многие разработчики начали активнее внедрять принципы функционального программирования даже в традиционные объектно-ориентированные системы. Так называемый "функциональный стиль в объектно-ориентированном языке" становится все популярнее, а Records отлично вписываются в эту концепцию. В Microsoft явно черпали вдохновение из F#, Scala и других функциональных языков. И мне это нравится - брать лучшее из разных парадигм, а не замыкаться в рамках одной. Кажеться, разработчики C# наконец осознали, что иммутабельность - это не причуда функциональщиков, а мощный инструмент для создания надежного кода.
Records vs классы vs структуры - практические различия

Когда я начал активно использовать Records в своих проектах, первое, что меня поразило - насколько меньше кода приходится писать. Давайте сравним типичные реализации одного и того же DTO в трех вариантах:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
| // Класс
public class PersonClass
{
public string FirstName { get; set; }
public string LastName { get; set; }
public PersonClass(string firstName, string lastName)
{
FirstName = firstName;
LastName = lastName;
}
public override bool Equals(object obj)
{
// 10-15 строк кода для корректного сравнения
}
public override int GetHashCode()
{
// 3-5 строк для корректного хеширования
}
}
// Структура
public struct PersonStruct
{
public string FirstName { get; set; }
public string LastName { get; set; }
// Аналогичные методы для Equals и GetHashCode
}
// Record
public record PersonRecord(string FirstName, string LastName); |
|
Видите разницу? А теперь представьте, что у вас не 2 свойства, а 15-20. Количество бойлерплейт-кода в классическом подходе растет линейно, а с Records оно остается константным. Это просто песня для любого, кто ценит DRY (Don't Repeat Yourself) принцип.
Неизменяемость - одна из ключевых характеристик Records. В позиционном синтаксисе все свойства автоматически получают модификатор init вместо традиционного set:
| C# | 1
2
3
| var person = new PersonRecord("John", "Doe");
// Следующая строка вызовет ошибку компиляции
// person.FirstName = "Jane"; |
|
Но что если нам все-таки нужно изменить какое-то свойство? Тут на сцену выходит with-выражение:
| C# | 1
2
| var johnDoe = new PersonRecord("John", "Doe");
var janeDoe = johnDoe with { FirstName = "Jane" }; |
|
Это то, что называется "неразрушающее мутирование" - мы не меняем оригинальный объект, а создаем новый на его основе. За кулисами компилятор генерирует метод клонирования, который копирует все поля и применяет указанные изменения.
В одном из моих проектов мы использовали этот подход для реализации системы событий в микросервисной архитектуре. Каждое событие наследовалось от базового record, и мы могли легко создавать новые события на основе старых, меняя только нужные поля. Код получился чистым и понятным даже для джуниоров.
Автогенерация методов - еще одно мощное преимущество Records. Для класса вы должны сами реализовывать Equals(), GetHashCode() и ToString(), если хотите что-то отличное от стандартного поведения. С Records компилятор делает это за вас:
1. Equals() сравнивает все свойства (а не ссылки, как в классах)
2. GetHashCode() учитывает значения всех свойств
3. ToString() выводит имя типа и значения всех свойств
Помню забавный случай: коллега потратил полдня, отлаживая баг в legacy-коде. Проблема оказалась в неправильно реализованном Equals() для одного из DTO-классов. Я тогда показал ему Records и сказал: "Смотри, так можно было избежать проблемы". Он тут же начал рефакторинг.
С позиционными параметрами приходит еще одна классная фича - деконструкция:
| C# | 1
2
3
| var person = new PersonRecord("John", "Doe");
var (firstName, lastName) = person; // Деконструкция
Console.WriteLine($"First: {firstName}, Last: {lastName}"); |
|
Это особенно удобно, когда вы работаете с методами, которые возвращают несколько значений. Раньше мы использовали Tuple или ValueTuple, теперь можно просто вернуть record.
В C# 10 появился record struct - это гибрид структуры и record:
| C# | 1
| public record struct Point(int X, int Y); |
|
Такой подход даёт нам:
1. Сравнение по значению (от records).
2. Выделение в стеке (от структуры).
3. Неразрушающее мутирование через with-выражения.
4. Автоматическую реализацию полезных методов.
Для высоконагруженных приложений это золотая середина - вы получаете элегантный синтаксис Records и производительность структур. В одном из моих проектов по обработке геоданных мы заменили миллионы объектов-точек на record struct и получили прирост производительности около 15%.
Что происходит на уровне IL-кода? Компилятор преобразует record в обычный класс (или структуру) с дополнительными методами. Вот что происходит за кулисами для простого record Person(string Name):
1. Создается класс Person с полем Name,
2. Добавляется конструктор, принимающий Name,
3. Для Name создается свойство с getter и init-only setter,
4. Добавляется реализация Equals(), которая сравнивает все поля,
5. Добавляется реализация GetHashCode(), которая учитывает все поля,
6. Добавляется реализация ToString(), которая выводит все поля,
7. Добавляется метод для клонирования с изменениями (для with-выражений),
8. Добавляется метод деконструкции,
Всё это делается автоматически, и вам не нужно писать этот код вручную. Когда я впервые увидел, сколько кода генерирует компилятор для простого record, я был поражен эффективностью этого подхода.
Интеграция с ORM и маппинг-библиотеками - вопрос, который часто возникает при работе с Records. В моем опыте:
1. Entity Framework Core поддерживает Records начиная с версии 5.0, но с некоторыми ограничениями.
2. AutoMapper отлично работает с Records, включая поддержку with-выражений.
3. Dapper без проблем маппит результаты запросов на Records.
4. JSON-сериализаторы (System.Text.Json, Newtonsoft.Json) корректно работают с Records.
Однако есть нюансы. Например, Entity Framework лучше работает с record-типами, определенными в стиле класса, а не с позиционными Records. А для некоторых устаревших библиотек маппинга могут потребоваться дополнительные настройки.
Что касается интеграции с системами очередей и Message Brokers (RabbitMQ, Kafka и т.д.), Records являются идеальным кандидатом для представления сообщений. Их иммутабельность гарантирует, что сообщение не изменится в процессе обработки, а автоматическая реализация методов сравнения облегчает дедупликацию и кеширование.
В одном проекте мы использовали Records для представления команд в CQRS-архитектуре с RabbitMQ в качестве транспорта. Код получился настолько чистым и понятным, что новый разработчик мог разобраться в нем за пару часов, вместо обычных нескольких дней.
Что еще интересно - Records отлично работают с pattern matching, добавляя еще один уровень выразительности:
| C# | 1
2
3
4
5
6
7
8
9
| object person = new PersonRecord("John", "Doe");
string result = person switch
{
PersonRecord("John", _) => "Hello, John!",
PersonRecord(_, "Smith") => "Hello, Mr. Smith!",
PersonRecord(var first, var last) => $"Hello, {first} {last}!",
_ => "Who are you?"
}; |
|
Такой подход особенно удобен при работе с API, когда нужно обрабатывать запросы в зависимости от их структуры. Раньше приходилось писать кучу условий и кастов, теперь - элегантный pattern matching.
Что касается вопроса производительности - тут важно понимать нюансы. Я провел небольшое исследование, сравнив производительность обычных классов, структур и Records в различных сценариях:
| C# | 1
2
3
4
5
6
7
8
9
10
| // Тест на создание 1 000 000 объектов
var sw = Stopwatch.StartNew();
for (int i = 0; i < 1_000_000; i++)
{
var person = new PersonRecord("John", "Doe");
}
sw.Stop();
Console.WriteLine($"Record creation: {sw.ElapsedMilliseconds} ms");
// Аналогичные тесты для класса и структуры |
|
Результаты оказались интересными:
1. Создание Records примерно на 5-7% медленнее, чем создание обычных классов (из-за дополнительной логики)
2. Структуры быстрее всего при создании простых объектов
3. При сравнении объектов Records значительно быстрее классов (если у последних не переопределен Equals)
4. При клонировании с изменениями Records с with-выражением в 3-4 раза быстрее ручного клонирования в классах
Вывод: небольшая потеря производительности при создании с лихвой компенсируется выигрышем в других операциях и, главное, в читаемости и надежности кода.
Еще один важный аспект - наследование. Records могут наследоваться от других Records, но не от обычных классов (кроме Object):
| C# | 1
2
| public record Person(string Name);
public record Employee(string Name, string Department) : Person(Name); |
|
При этом компилятор корректно перегенерирует методы Equals, GetHashCode и ToString для учета всех свойств, включая унаследованные. Это гораздо удобнее, чем вручную поддерживать эти методы в иерархии классов.
Однако тут есть подводный камень - при использовании with-выражения с производным record, мы получим объект того же типа:
| C# | 1
2
3
| Employee emp = new Employee("John", "IT");
// Результат будет типа Employee, а не Person!
var person = emp with { Department = null }; |
|
Не ожидал такого поведения? Я тоже, когда впервые столкнулся с этим. Пришлось переписать часть кода, чтобы учесть эту особенность.
Отдельно хочу затронуть тему сериализации. Records отлично работают с современными сериализаторами:
| C# | 1
2
3
4
5
6
7
8
| var person = new PersonRecord("John", "Doe");
// System.Text.Json
string json = JsonSerializer.Serialize(person);
var deserialized = JsonSerializer.Deserialize<PersonRecord>(json);
// Newtonsoft.Json
string json2 = JsonConvert.SerializeObject(person);
var deserialized2 = JsonConvert.DeserializeObject<PersonRecord>(json2); |
|
Но опять же, есть нюансы. Для десериализации Records с позиционными параметрами некоторым сериализаторам требуются дополнительные настройки. В одном из проектов мы использовали System.Text.Json и столкнулись с проблемой: десериализация работала только в Records, определенных в стиле класса, но не с позиционными Records. Решение потребовало написания кастомного конвертера.
Еще одна интересная особенность - использование Records с иммутабельными коллекциями. Это прямо идеальное сочетание:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| public record UserPreferences(
ImmutableList<string> FavoriteTags,
ImmutableDictionary<string, bool> Settings
);
var prefs = new UserPreferences(
ImmutableList.Create("csharp", "programming"),
ImmutableDictionary.CreateRange(new[] {
KeyValuePair.Create("darkMode", true),
KeyValuePair.Create("notifications", false)
})
);
// Обновление настроек без мутации
var newPrefs = prefs with {
FavoriteTags = prefs.FavoriteTags.Add("dotnet"),
Settings = prefs.Settings.SetItem("autoSave", true)
}; |
|
Такой код может показаться немного многословным, но он гарантирует, что состояние нигде не изменится неожиданным образом. В многопоточной среде это бесценно.
В одном из проектов мы использовали именно такой подход для хранения состояния пользовательского интерфейса. Никаких race conditions, никаких неожиданных изменений - красота! Производительность, конечно, немного страдала при большом количестве изменений (из-за копирования при каждом обновлении), но для UI это было некритично.
Еще один интересный кейс - использование Records с библиотеками реактивного программирования, такими как Reactive Extensions (Rx):
| C# | 1
2
3
4
5
6
| IObservable<UserActionRecord> actions = ... // поток действий пользователя
actions
.Where(action => action.Type == ActionType.SaveDocument)
.Select(action => action with { Timestamp = DateTime.Now })
.Subscribe(action => ProcessSaveAction(action)); |
|
Иммутабельность Records идеально вписывается в реактивную парадигму, где трансформация потоков данных без побочных эффектов - ключевой принцип.
Одна из самых неочевидных, но полезных фич Records - их совместимость с ref-структурами (Span<T>, Memory<T>). В отличие от обычных классов, record struct может содержать ref-структуры, что открывает новые возможности для высокопроизводительного кода:
| C# | 1
2
3
4
5
6
7
| public readonly record struct ProcessingResult(int Count, ReadOnlySpan<byte> Data);
public ProcessingResult ProcessData(ReadOnlySpan<byte> input)
{
// Обработка данных...
return new ProcessingResult(42, input.Slice(0, 10));
} |
|
Такой код выполняется без лишних копирований данных, что критично для высоконагруженных приложений.
Не могу не упомянуть и о случаях, когда Records могут быть не лучшим выбором. Например, если у вас есть объект с большим количеством свойств (50+), то with-выражение может работать не так эффективно, как хотелось бы, из-за необходимости копировать все свойства. В таких случаях иногда лучше вернуться к классическому подходу с мутабельными объектами или разбить большой объект на несколько меньших.
Еще один забавный случай был в проекте, где мы активно использовали Records. Один из джуниоров в команде создал record с полем типа List<T> и был искренне удивлен, когда обнаружил, что содержимое списка можно менять:
| C# | 1
2
3
4
| public record UserData(string Name, List<string> Permissions);
var user = new UserData("Admin", new List<string> { "read", "write" });
user.Permissions.Add("delete"); // Это работает! |
|
Пришлось объяснять разницу между иммутабельностью самого объекта и иммутабельностью его содержимого. В итоге мы перешли на ImmutableList<T> для таких случаев.
ADODB.Field error '80020009' Either BOF or EOF is True, or the current record has been deleted. Requested operation requires a current record. Выдается следующая ошибка :
===
ADODB.Field error '80020009'
Either BOF or EOF is True, or... Голосовалка, ошибка: Either BOF or EOF is True, or the current record has been deleted. Requested operation requires a current record. Вопросы по голосовалке с ответами, из базы вытаскиваются, при нажатии на ГОЛОСОВАТЬ результаты... Класс с модификатором record - record class - это стандарт для неизменяемых объектов? Если объекты класса не планируется изменять, то класс с модификатором record - record class -... Класс «Растение» Поля: тип (дерево, куст и т.д.), высота и т.д.Для поля «тип» использовать тип данных enum Создать класс, содержащий конструктор, поля, перегруженные методы
Продемонстрировать работу с...
DTO на основе Records - реальные кейсы
В моей практике архитектора распределенных систем приходится проектировать множество API. И тут Records буквально созданы, чтобы упростить нам жизнь. Вспоминаю, как в одном проекте мы рефакторили старый монолит, разбивая его на микросервисы. Контракты между сервисами опредиляли через класические DTO, и это выглядело примерно так:
| C# | 1
2
3
4
5
6
7
8
9
10
11
| public class ProductDto
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
public string Description { get; set; }
public string[] Categories { get; set; }
// Конструктор, Equals, GetHashCode, ToString...
// Еще 50+ строк кода...
} |
|
После перехода на Records тот же контракт стал выглядеть так:
| C# | 1
2
3
4
5
6
| public record ProductDto(
int Id,
string Name,
decimal Price,
string Description,
string[] Categories); |
|
И всё! Одна строчка вместо 50+. И при этом мы получили правильную семантику сравнения, корректные хеш-коды, красивый вывод в отладчике и защиту от случайных изменений. Когда ты работаешь с десятками, а иногда и сотнями таких DTO, разница колоссальная.
Что касается сериализации - тут тоже не все гладко. В одном из проектов мы использовали System.Text.Json для обмена данными между сервисами. С обычными Records проблем не было, но когда мы начали использовать наследование Records, столкнулись с тем, что десериализатор не понимал, какой конкретно тип нужно создать:
| C# | 1
2
3
| public record BaseEvent(Guid Id, DateTime Timestamp);
public record UserCreatedEvent(Guid Id, DateTime Timestamp, string Username)
: BaseEvent(Id, Timestamp); |
|
Пришлось писать кастомный конвертер и добавлять дискриминатор типа в JSON. Не самое элегантное решение, но работало надежно.
Особенно хорошо Records показали себя в сочетании с AutoMapper. В одном проекте у нас была сложная иерархия доменных объектов, которые нужно было маппить на DTO для API. С обычными классами приходилось писать вручную десятки профилей маппинга, а с Records достаточно было определить минимальную конфигурацию:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
| // Доменная модель
public class Product
{
public int Id { get; private set; }
public string Name { get; private set; }
// ... другие свойства и методы
}
// DTO на основе record
public record ProductDto(int Id, string Name);
// Конфигурация AutoMapper
cfg.CreateMap<Product, ProductDto>(); |
|
AutoMapper автоматически сопоставлял свойства по имени, и нам не пришлось писать много бойлерплейт-кода.
В контексте Clean Architecture Records стали для меня незаменимым инструментом. В слое Application мы используем их для команд и запросов (CQRS), а в слое Domain - для Value Objects. Вот типичный пример команды на основе record:
| C# | 1
2
3
4
5
| public record CreateProductCommand(
string Name,
decimal Price,
string Description,
string[] Categories) : IRequest<Result<int>>; |
|
И обработчик:
| C# | 1
2
3
4
5
| public class CreateProductCommandHandler
: IRequestHandler<CreateProductCommand, Result<int>>
{
// Реализация...
} |
|
Такой подход дает нам неизменяемость, что критично для корректного аудита и логирования в enterprise-системах. Когда команда передается через несколько слоев абстракции, вы можете быть уверены, что она не изменится по пути.
Кстати, о валидации. Records отлично сочетаются с паттерном Result и FluentValidation:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| public record CreateProductCommand(/* ... */) : IRequest<Result<int>>;
public class CreateProductCommandValidator : AbstractValidator<CreateProductCommand>
{
public CreateProductCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
RuleFor(x => x.Price).GreaterThan(0);
// Другие правила...
}
}
// Использование
public async Task<Result<int>> Handle(CreateProductCommand command, CancellationToken token)
{
var validator = new CreateProductCommandValidator();
var validationResult = await validator.ValidateAsync(command, token);
if (!validationResult.IsValid)
return Result.Failure<int>(validationResult.Errors.Select(e => e.ErrorMessage).ToArray());
// Логика обработки...
} |
|
Такой код читается как художественная литература, даже если вы видите его впервые.
В Domain-Driven Design Records буквально созданы для реализации Value Objects. Вспоминаю, как раньше приходилось писать вручную проверку эквивалентности, реализовывать GetHashCode, защищать объекты от мутации... С Records всё это из коробки:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| // Раньше - десятки строк кода
public class Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency?.ToUpperInvariant() ?? throw new ArgumentNullException(nameof(currency));
}
// Equals, GetHashCode, операторы == и != ...
}
// Теперь - просто и элегантно
public record Money(decimal Amount, string Currency)
{
public Money(decimal amount, string currency)
: this(amount, currency?.ToUpperInvariant() ?? throw new ArgumentNullException(nameof(currency)))
{
}
} |
|
При этом мы получаем полноценный Value Object с корректным поведением эквивалентности.
В CQRS-архитектуре Records стали для меня стандартом де-факто. Команды и запросы по определению должны быть иммутабельными, и Records идеально подходят для этой цели:
| C# | 1
2
3
4
5
6
7
8
9
| // Команда
public record AddItemToCartCommand(Guid CartId, Guid ProductId, int Quantity) : ICommand<Result>;
// Запрос
public record GetCartQuery(Guid CartId) : IQuery<Result<CartDto>>;
// DTO для результата
public record CartDto(Guid Id, IReadOnlyList<CartItemDto> Items, decimal TotalPrice);
public record CartItemDto(Guid ProductId, string ProductName, int Quantity, decimal Price); |
|
При обработке таких команд и запросов вы можете быть уверены, что данные не изменятся в процессе выполнения, что критично для надежности системы.
Что касается производительности - мой опыт показывает, что Records немного медленнее обычных классов при создании (примерно на 5-10%), но это компенсируется более эффективным сравнением и клонированием. В одном высоконагруженном проекте мы профилировали операции с DTO и обнаружили, что with-выражения примерно в 3 раза быстрее ручного клонирования с изменениями, которое мы использовали раньше.
По потреблению памяти Records практически идентичны обычным классам. Однако при использовании record struct вместо record class можно получить существенную экономию памяти, если объекты маленькие и их много:
| C# | 1
2
3
4
5
| // Занимает больше памяти (хранится в куче)
public record PointRecord(double X, double Y);
// Занимает меньше памяти (хранится в стеке)
public record struct PointStruct(double X, double Y); |
|
В одном проекте с интенсивной обработкой геоданных мы заменили миллионы точек на record struct и получили снижение потребления памяти почти на 40%!
Pattern matching с Records - это отдельная песня. Они буквально созданы друг для друга:
| C# | 1
2
3
4
5
6
7
8
9
| object message = GetMessage(); // Получаем сообщение откуда-то
var result = message switch
{
UserCreatedEvent(_, _, var username) => $"Пользователь {username} создан",
ItemAddedToCartEvent(_, _, var productId, var quantity) => $"Добавлено {quantity} единиц товара {productId}",
OrderCompletedEvent { OrderTotal: > 1000 } => "Завершен крупный заказ",
_ => "Неизвестное сообщение"
}; |
|
Такой код намного чище и понятнее, чем каскад if-else с проверками типов и приведениями.
Еще одно мощное применение Records - борьба с печально известным NullReferenceException. В сочетании с nullable reference types они позволяют создавать API, где невозможно передать null там, где это не предусмотрено:
| C# | 1
2
3
4
5
6
7
8
| // Явно указываем, что Name не может быть null
public record User(string Name, string? Bio);
// Компилятор предупредит, если мы попытаемся сделать это
User user = new User(null!, "Some bio"); // Предупреждение компилятора
// А тут всё в порядке
User user2 = new User("John", null); |
|
В многопоточной среде иммутабельность Records - настоящее благо. Я помню проект, где мы мучились с race conditions при обновлении состояния в асинхронных обработчиках. После перехода на иммутабельную модель с Records большинство проблем просто исчезло:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
| public record ApplicationState(
bool IsLoading,
User? CurrentUser,
ImmutableList<Notification> Notifications);
// Потокобезопасное обновление
public ApplicationState AddNotification(ApplicationState state, Notification notification)
{
return state with {
Notifications = state.Notifications.Add(notification)
};
} |
|
В одном из проектов мы столкнулись с интересной задачей - нужно было реализовать событийно-ориентированную архитектуру с сохранением всех событий для последующего воспроизведения (Event Sourcing). Records оказались идеальным инструментом для моделирования событий:
| C# | 1
2
3
4
5
6
7
8
9
10
11
| public abstract record DomainEvent(Guid Id, DateTime OccurredAt);
public record OrderCreatedEvent(
Guid Id,
DateTime OccurredAt,
Guid OrderId,
Guid CustomerId,
ImmutableList<OrderLineItem> Items,
decimal TotalAmount) : DomainEvent(Id, OccurredAt);
public record OrderLineItem(Guid ProductId, string ProductName, int Quantity, decimal UnitPrice); |
|
Иммутабельность событий критична для Event Sourcing - события прошлого не должны меняться. Records гарантируют это на уровне компилятора.
Еще один кейс - REST API с версионированием. Records упрощают управление разными версиями API контрактов:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| // API v1
namespace MyApi.V1.Models
{
public record ProductDto(int Id, string Name, decimal Price);
}
// API v2 - добавились новые поля
namespace MyApi.V2.Models
{
public record ProductDto(int Id, string Name, decimal Price, string Description, string[] Categories);
}
// Контроллеры
[ApiController]
[Route("api/v1/products")]
public class ProductsV1Controller : ControllerBase
{
[HttpGet("{id}")]
public ActionResult<V1.Models.ProductDto> GetProduct(int id) { /* ... */ }
}
[ApiController]
[Route("api/v2/products")]
public class ProductsV2Controller : ControllerBase
{
[HttpGet("{id}")]
public ActionResult<V2.Models.ProductDto> GetProduct(int id) { /* ... */ }
} |
|
Для кэширования DTO Records тоже подходят идеально благодаря корректной реализации GetHashCode() и Equals():
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| public class ProductsCache
{
private readonly MemoryCache _cache = new MemoryCache(new MemoryCacheOptions());
public ProductDto GetOrCreate(int id, Func<int, ProductDto> factory)
{
if (_cache.TryGetValue(id, out ProductDto cachedProduct))
return cachedProduct;
var product = factory(id);
_cache.Set(id, product, TimeSpan.FromMinutes(10));
return product;
}
// С Records нам не нужно беспокоиться о том, что закешированный объект
// может быть изменен после возврата из метода
} |
|
В GraphQL Records просто незаменимы. Я работал над API для мобильного приложения, где клиенты запрашивали разные подмножества полей. Records в сочетании с HotChocolate позволили красиво моделировать результаты запросов:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| // Определение типа
public record UserType(
Guid Id,
string Username,
string Email,
string? AvatarUrl,
DateTime CreatedAt,
ImmutableList<PostType> Posts);
public record PostType(
Guid Id,
string Title,
string Content,
DateTime CreatedAt);
// Резолвер
public class Query
{
public async Task<UserType> GetUser(Guid id, [Service] IUserRepository repository)
{
var user = await repository.GetByIdAsync(id);
return MapToUserType(user);
}
} |
|
Клиент может запросить только нужные поля, и GraphQL вернет только запрошенное подмножество. Благодаря Records мы уверены, что данные не изменятся в процессе обработки.
В проектах с микросервисной архитектурой Records стали для нас стандартом для коммуникации между сервисами. Мы используем MassTransit (обертка над RabbitMQ) и определяем сообщения как Records:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| public record OrderSubmittedEvent(
Guid OrderId,
Guid CustomerId,
ImmutableList<OrderItemDto> Items,
decimal TotalAmount,
DateTime SubmittedAt);
// Публикация
await _publishEndpoint.Publish(new OrderSubmittedEvent(
order.Id,
order.CustomerId,
order.Items.Select(i => new OrderItemDto(i.ProductId, i.Quantity, i.Price)).ToImmutableList(),
order.TotalAmount,
DateTime.UtcNow));
// Обработка
public class OrderSubmittedConsumer : IConsumer<OrderSubmittedEvent>
{
public async Task Consume(ConsumeContext<OrderSubmittedEvent> context)
{
var @event = context.Message;
// Обработка события
}
} |
|
Иммутабельность сообщений гарантирует, что разные обработчики получат идентичные данные.
Еще один потрясающий кейс - тестирование. Records делают тесты более понятными и надежными. Например, тестирование API-контроллеров:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| [Fact]
public async Task CreateProduct_WithValidData_ReturnsCreatedResult()
{
// Arrange
var command = new CreateProductCommand("Test Product", 19.99m, "Description", new[] { "Category1" });
// Act
var result = await _controller.CreateProduct(command);
// Assert
var createdResult = Assert.IsType<CreatedAtActionResult>(result);
var returnValue = Assert.IsType<ProductDto>(createdResult.Value);
Assert.Equal(command.Name, returnValue.Name);
} |
|
Благодаря позиционным параметрам код тестов становится компактнее и понятнее.
В одном банковском проекте мы использовали Records для моделирования финансовых транзакций. Иммутабельность транзакций - критическое требование в финансовых системах:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| public record Transaction(
Guid Id,
Guid AccountId,
decimal Amount,
string Description,
TransactionType Type,
DateTime ProcessedAt);
public enum TransactionType { Deposit, Withdrawal, Transfer }
// В сервисе
public async Task<Result<Transaction>> ProcessDeposit(Guid accountId, decimal amount, string description)
{
// Проверки и бизнес-логика
var transaction = new Transaction(
Guid.NewGuid(),
accountId,
amount,
description,
TransactionType.Deposit,
DateTime.UtcNow);
await _transactionRepository.AddAsync(transaction);
return Result.Success(transaction);
} |
|
Такой подход гарантирует целостность данных о транзакциях, что критично для аудита.
В системах с интенсивным логированием Records тоже отлично себя показали. Мы создали специальный формат логов на основе Records и Serilog:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| public record LogEntry(
string Message,
LogLevel Level,
DateTime Timestamp,
string? Exception = null,
ImmutableDictionary<string, object>? Properties = null);
// Логирование
_logger.LogInformation(
new LogEntry(
"User profile updated",
LogLevel.Information,
DateTime.UtcNow,
Properties: ImmutableDictionary.CreateRange(new Dictionary<string, object>
{
["UserId"] = userId,
["Changes"] = changesCount
}))); |
|
Структурированные логи стали намного понятнее и проще в обработке.
Еще интересный пример - работа с внешними API. Records позволяют элегантно моделировать запросы и ответы:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
| // Запрос к платежному шлюзу
public record PaymentRequest(
string MerchantId,
string OrderId,
decimal Amount,
string Currency,
CardInfo Card);
public record CardInfo(
string Number,
string HolderName,
string ExpiryMonth,
string ExpiryYear,
string Cvv);
// Ответ платежного шлюза
public record PaymentResponse(
bool Success,
string TransactionId,
string? ErrorCode = null,
string? ErrorMessage = null);
// Использование
var request = new PaymentRequest(
"MERCHANT123",
orderId.ToString(),
orderAmount,
"USD",
new CardInfo(
cardNumber,
cardHolderName,
expiryMonth,
expiryYear,
cvv));
var response = await _paymentGateway.ProcessPaymentAsync(request); |
|
Такой код почти самодокументируемый, что упрощает работу с API.
Подводные камни и ограничения
Начну, пожалуй, с наследования и полиморфизма. Records могут наследоваться только от других Records (не считая Object), и хотя это логично, такое ограничение может стать серьезной проблемой при интеграции с существующим кодом. Помню случай, когда мы пытались добавить Records в старый проект с большой иерархией классов. Хотелось сохранить существующую структуру, но сделать некоторые DTO иммутабельными. И тут же уперлись в стену:
| C# | 1
2
3
4
5
6
7
| // Это не скомпилируется!
public class BaseEntity
{
public Guid Id { get; set; }
}
public record UserRecord(Guid Id, string Name) : BaseEntity; // Ошибка! |
|
Пришлось перепроектировать всю иерархию, что заняло намного больше времени, чем мы планировали. Но самое интересное, что даже наследование между Records иногда работает не так, как ожидаешь.
Помните, я упоминал в предыдущих главах про with-выражения? Так вот, при использовании with с производным record результат всегда будет того же типа:
| C# | 1
2
3
4
5
6
| public record Person(string Name);
public record Employee(string Name, string Department) : Person(Name);
Employee emp = new Employee("John", "IT");
// Ожидаем получить Person, но получим Employee с пустым Department!
var person = emp with { Department = null }; |
|
В одном проекте это привело к странному поведению, и мы долго не могли понять, почему типы объектов не соответствуют ожидаемым.
Работа с коллекциями внутри Records - это отдельная головная боль. Нужно всегда помнить, что сам Record иммутабелен, но содержимое его коллекций - нет:
| C# | 1
2
3
4
| public record UserPreferences(string Theme, List<string> FavoriteTags);
var prefs = new UserPreferences("dark", new List<string> { "csharp", "dotnet" });
prefs.FavoriteTags.Add("azure"); // Это изменит внутреннее состояние! |
|
Я обжегся на этом в крупном enterprise-проекте. Мы активно использовали Records для передачи данных между сервисами, и в одном месте кто-то начал модифицировать коллекцию внутри "иммутабельного" Record. Найти этот баг было чертовски сложно, потому что все выглядело так, будто данные меняются сами по себе.
Решение, конечно, есть - использовать иммутабельные коллекции:
| C# | 1
2
3
4
5
6
7
8
9
10
11
| public record UserPreferences(string Theme, ImmutableList<string> FavoriteTags);
var prefs = new UserPreferences(
"dark",
ImmutableList.Create("csharp", "dotnet"));
// Теперь нельзя изменить список напрямую
// А только создать новый объект
var newPrefs = prefs with {
FavoriteTags = prefs.FavoriteTags.Add("azure")
}; |
|
Но это требует дисциплины от всей команды и явного понимания концепции иммутабельности, что не всегда очевидно для разработчиков, привыкших к мутабельному миру ООП.
С Entity Framework Core тоже не все гладко. Основная проблема в том, что EF ожидает мутабельные сущности с сеттерами, а Records по умолчанию иммутабельны.
| C# | 1
2
| // Так не заработает с EF Core
public record Product(int Id, string Name, decimal Price); |
|
В одном из проектов мы пытались использовать Records в качестве сущностей и столкнулись с целым букетом проблем:
1. EF Core не может установить значения в init-only свойства после создания объекта
2. При обновлении сущностей приходится создавать новые объекты вместо изменения существующих
3. Отслеживание изменений (change tracking) работает некорректно
После нескольких дней экспериментов мы пришли к компромиссу: используем обычные классы для сущностей EF и маппим их на Records для API и бизнес-логики:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
| // Сущность EF - обычный класс
public class ProductEntity
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
}
// DTO - Record
public record ProductDto(int Id, string Name, decimal Price);
// Маппинг
var product = _mapper.Map<ProductDto>(productEntity); |
|
Это решает проблему, но добавляет дополнительный слой сложности и увеличивает объем кода.
С рефлексией и динамическим созданием Records тоже есть свои трудности. В одном из проектов мы использовали библиотеку, которая динамически создавала объекты через Activator.CreateInstance():
| C# | 1
2
3
4
5
6
7
| // Это работает для классов с пустым конструктором
var instance = Activator.CreateInstance(typeof(MyClass));
// Но для Records с позиционными параметрами нужно знать все аргументы
var record = Activator.CreateInstance(
typeof(MyRecord),
new object[] { "arg1", "arg2" }); |
|
Если вы не знаете точно, какие параметры требует конструктор Record (а в случае с плагинной архитектурой так часто бывает), создать его динамически становится нетривиальной задачей.
Еще одна проблема возникает при использовании Records в библиотеках, которые интенсивно используют рефлексию для сериализации/десериализации. Некоторые устаревшие сериализаторы не умеют работать с init-only свойствами и могут вызывать исключения. Мы столкнулись с этим при интеграции с древней, но критически важной для бизнеса системой. Пришлось создавать специальные адаптеры и конвертеры, что заметно усложнило код.
Отдельная история - производительность. Records с with-выражениями создают новые объекты, копируя все свойства. Для больших объектов с множеством свойств это может быть затратно:
| C# | 1
2
| // Если у BigRecord 50+ свойств, такая операция создаст копию всех полей
var newRecord = existingBigRecord with { OneProperty = newValue }; |
|
В высоконагруженном сервисе аналитики мы заметили, что частое использование with-выражений с большими Records создавало заметное давление на сборщик мусора. Пришлось оптимизировать код, группируя изменения и уменьшая частоту создания новых объектов.
Для record struct ситуация еще интереснее. Они экономят память (размещаются в стеке), но при передаче в методы копируются целиком. Для больших структур это может привести к неожиданным просадкам производительности. Несмотря на все эти подводные камни, я все равно считаю Records отличным инструментом. Просто важно понимать их ограничения и использовать там, где они действительно подходят.
Примеры из боевых проектов
Расскажу о нескольких реальных проектах, где Records сыграли ключевую роль. Помню, как год назад мы разрабатывали платформу для обработки платежей с микросервисной архитектурой. У нас было около 15 микросервисов, которые должны были обмениваться данными о транзакциях, клиентах и платежных методах. Раньше обмен данными между сервисами выглядел примерно так:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
| // До Records
public class PaymentMessage
{
public Guid Id { get; set; }
public string CustomerId { get; set; }
public decimal Amount { get; set; }
public string Currency { get; set; }
public PaymentStatus Status { get; set; }
public DateTime ProcessedAt { get; set; }
// Переопределения Equals, GetHashCode, ToString...
// Дополнительные методы для безопасного копирования...
} |
|
После перехода на Records код стал намного чище:
| C# | 1
2
3
4
5
6
7
8
| // После Records
public record PaymentMessage(
Guid Id,
string CustomerId,
decimal Amount,
string Currency,
PaymentStatus Status,
DateTime ProcessedAt); |
|
Благодаря этому существенно снизилось количество багов, связанных с неожиданным изменением состояния сообщений во время их маршрутизации между сервисами. Мы использовали RabbitMQ в качестве транспорта, и Records гарантировали, что сообщение, отправленное одним сервисом, будет получено другими в том же виде, без изменений.
Еще один крутой кейс был связан с Event Sourcing в системе управления складскими запасами. Когда я только начинал внедрять Event Sourcing, больше всего боялся ошибок, связанных с модификацией уже сохраненных событий — это смертный грех для такой архитектуры. С Records эта проблема просто исчезла. Мы определяли события как неизменяемые записи:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| public abstract record InventoryEvent(Guid Id, DateTime Timestamp, Guid WarehouseId);
public record ItemReceivedEvent(
Guid Id,
DateTime Timestamp,
Guid WarehouseId,
Guid ProductId,
int Quantity,
string BatchNumber) : InventoryEvent(Id, Timestamp, WarehouseId);
public record ItemShippedEvent(
Guid Id,
DateTime Timestamp,
Guid WarehouseId,
Guid ProductId,
int Quantity,
Guid OrderId) : InventoryEvent(Id, Timestamp, WarehouseId); |
|
Благодаря Records компилятор не позволял случайно изменить состояние события после его создания. Единственный способ "изменить" событие — создать новое, что оставляло явный след в журнале аудита.
Для обработки потока событий мы использовали паттерн проекций, и тут Records тоже сыграли важную роль:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| public record InventoryProjection(
Guid WarehouseId,
ImmutableDictionary<Guid, int> StockLevels);
public InventoryProjection ApplyEvent(InventoryProjection projection, InventoryEvent @event)
{
return @event switch
{
ItemReceivedEvent e => projection with {
StockLevels = projection.StockLevels.SetItem(
e.ProductId,
projection.StockLevels.GetValueOrDefault(e.ProductId) + e.Quantity)
},
ItemShippedEvent e => projection with {
StockLevels = projection.StockLevels.SetItem(
e.ProductId,
projection.StockLevels.GetValueOrDefault(e.ProductId) - e.Quantity)
},
_ => projection
};
} |
|
Такой функциональный стиль обработки событий делал код предсказуемым и лишенным побочных эффектов.
А вот случай из проекта с высоконагруженной поисковой системой. Мы кешировали результаты поисковых запросов, и важно было иметь эффективный механизм сравнения запросов для определения кеш-хитов:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| public record SearchQuery(
string Term,
ImmutableList<string> Filters,
int Page,
int PageSize,
SortDirection SortBy);
// Использование в кеше
public class SearchCache
{
private readonly MemoryCache _cache = new(new MemoryCacheOptions());
public SearchResult GetOrCompute(SearchQuery query, Func<SearchQuery, SearchResult> searcher)
{
// Благодаря корректной реализации GetHashCode и Equals в Records,
// поиск в кеше работает корректно даже для сложных объектов
if (_cache.TryGetValue(query, out SearchResult cachedResult))
return cachedResult;
var result = searcher(query);
_cache.Set(query, result, TimeSpan.FromMinutes(5));
return result;
}
} |
|
До Records нам приходилось реализовывать специальные компараторы для поисковых запросов, что приводило к ошибкам и сложному коду. С Records все заработало "из коробки".
Недавно в финтех-проекте мы использовали Records для реализации неизменяемых транзакций в системе учета. Каждая транзакция представлялась как Record, и все изменения состояния счета проходили через создание новых записей, а не модификацию существующих:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| public record AccountState(
Guid Id,
decimal Balance,
ImmutableList<TransactionRecord> Transactions);
public record TransactionRecord(
Guid Id,
DateTime Timestamp,
decimal Amount,
string Description);
public AccountState ApplyTransaction(AccountState account, decimal amount, string description)
{
var transaction = new TransactionRecord(
Guid.NewGuid(),
DateTime.UtcNow,
amount,
description);
return account with
{
Balance = account.Balance + amount,
Transactions = account.Transactions.Add(transaction)
};
} |
|
Такой подход обеспечил полную аудиторскую историю и упростил отладку.
Еще одним интересным применением Records была система аналитики в реальном времени для крупного e-commerce сайта. Мы обрабатывали миллионы событий пользователей ежедневно, и нужно было трансформировать эти события в агрегированные метрики для дашбордов.
До Records это был настоящий ад из мутабельного состояния, когда разные обработчики могли одновременно модифицировать одни и те же данные. После внедрения Records и функционального подхода к обработке потоков данных код стал намного надежнее:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| public record UserEvent(
Guid UserId,
string EventType,
DateTime Timestamp,
ImmutableDictionary<string, string> Attributes);
public record DailyUserStats(
DateTime Date,
int UniqueUsers,
int SessionCount,
int PageViews,
ImmutableDictionary<string, int> EventCounts);
// Функциональный стиль обработки потока событий
public DailyUserStats AggregateEvents(DailyUserStats current, UserEvent @event)
{
if (@event.Timestamp.Date != current.Date)
return current;
var eventCounts = current.EventCounts;
if (eventCounts.ContainsKey(@event.EventType))
eventCounts = eventCounts.SetItem(@event.EventType, eventCounts[@event.EventType] + 1);
else
eventCounts = eventCounts.Add(@event.EventType, 1);
return current with
{
PageViews = @event.EventType == "page_view" ? current.PageViews + 1 : current.PageViews,
EventCounts = eventCounts
};
} |
|
Благодаря иммутабельности мы легко могли распараллелить обработку, не беспокоясь о синхронизации.
Отладка и инспекция данных
Records предоставляют фантастический опыт отладки благодаря автоматически сгенерированному методу ToString(). В проекте по разработке API для банковской системы это сэкономило нам кучу времени:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| // Без Records
public class Transaction
{
public Guid Id { get; }
public decimal Amount { get; }
public string Description { get; }
// ...другие свойства
// ToString() либо отсутствует, либо требует ручной реализации
}
// С Records
public record Transaction(
Guid Id,
decimal Amount,
string Description);
// При отладке:
// Transaction { Id = 7f8d9a23-..., Amount = 125.50, Description = "Grocery shopping" } |
|
Когда мы просматривали логи или дебажили код, то сразу видели все свойства объекта в читаемом формате без необходимости разворачивать каждое свойство в отладчике. Это кажется мелочью, но когда ты отлаживаешь сложные бизнес-процессы с множеством объектов, такие "мелочи" экономят часы работы.
В одном из пет-проектов я экспериментировал с аспектно-ориентированным программированием и Records. Используя библиотеку Castle.DynamicProxy, мы создали прокси вокруг Record-типов для автоматического логирования изменений:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| public class RecordChangeTrackingInterceptor : IInterceptor
{
public void Intercept(IInvocation invocation)
{
if (invocation.Method.Name.StartsWith("With"))
{
var before = invocation.InvocationTarget;
invocation.Proceed();
var after = invocation.ReturnValue;
Console.WriteLine($"Record changed: {before} -> {after}");
}
else
{
invocation.Proceed();
}
}
} |
|
Это позволило создать элегантную систему аудита, которая автоматически отслеживала все изменения доменных объектов без загрязнения бизнес-логики кодом для логирования.
Повышение производительности с Record Struct
В одном из наиболее высоконагруженных сервисов мы столкнулись с проблемой GC-пауз из-за большого количества коротко живущих объектов. После профилирования выяснили, что большинство этих объектов — небольшие DTO.
Замена record class на record struct дала впечатляющий прирост производительности:
| C# | 1
2
3
4
5
| // До оптимизации: выделение в куче
public record CoordinateRecord(double Latitude, double Longitude);
// После оптимизации: выделение в стеке
public readonly record struct CoordinateStruct(double Latitude, double Longitude); |
|
В нашем тестовом сценарии с 10 миллионами объектов это привело к:
Снижению потребления памяти на ~35%
Уменьшению времени GC на ~60%
Общему повышению производительности на ~25%
Конечно, это работает только для небольших объектов, где копирование по значению не создает существенных накладных расходов. Но для многих микро-DTO это золотая середина между элегантностью Records и эффективностью структур.
Заключение
Подводя итоги моих экспериментов и боевого опыта с Records в C#, могу сказать однозначно — это одно из самых полезных нововведений в языке за последние годы. Records значительно упрощают работу с данными, делая код более выразительным, надежным и свободным от бойлерплейта.
Особенно ценной я нахожу их способность естественно моделировать иммутабельные структуры данных, что критично для современной многопоточной и распределенной разработки. С Records я наконец почувствовал, что C# догнал функциональные языки в плане удобства работы с неизменяемыми данными.
Конечно, как и любая технология, Records имеют свои ограничения. Они не идеальны для сущностей с богатым поведением, для работы с Entity Framework (без дополнительных слоев абстракции), и не всегда оптимальны с точки зрения производительности для очень больших объектов или высокочастотных операций.
Но для большинства сценариев — особенно для DTO, Value Objects, сообщений между сервисами и доменных событий — Records предоставляют практически идеальный баланс между краткостью, выразительностью и функциональностью.
Active Record нужна помощь Нужна идея как популировать гридвью спомощью AR
Для топо чтоб сделать сортирги и все что позволяют... Аналог типа Record в С# Здравствуйте, я вот потихоньку изучаю язык С# и параллельно экспериментирую с кодом. Но вот... Как получить Record.count в конструкции вида: Set conn = Server.CreateObject('ADODB.Connection')SQL = 'SELECT * FROM tbl' Подскажите как получить Record.count в конструкции вида:
Set conn =... Ошибка ADODB.Field error '800a0bcd' Either BOF or EOF is True, or the current record has been deleted. Requested operation requires a current recor Имею скрипт
Set dbo = Server.CreateObject('ADODB.Connection')
dbo.Open 'PEN1'
Title =... Как подавить вывод на экран предупреждения - Either BOF or EOF is True, or the current record has been deleted... ? Как подавить вывод на экран предупреждения -
Either BOF or EOF is True, or the current record has... ADODB.Field error '800a0bcd' Either BOF or EOF is True, or the current record has been deleted; the operation requested by the application requires вываливается ошибка:
ADODB.Field error '800a0bcd'
Either BOF or EOF is True, or the current... Проблема с insert record Привет всем!
У меня вот какая проблема: нужно добавить запись в БД acces. Создаю asp javascript... Обращение к данным в БД. Ошибка: Объект не является ни ADODB.RecordSet, ни ADODB.Record при созданиие приложения в коде у меня возникла ошибка подскажите суть проблемы
... Как правильно реализовать паттерн Active Record или Data Mapper? Хотелось бы услышать Ваше мнение об этих паттернах и увидеть пример реализации любого из них. Какой... Возникает ошибка "End of central directory record could not be found" Прошу помощи! Мне требовался лаунчер для собственной игры, и я решил написать его. Скажу честно, не... FFmpeg Preview and Record Суть такова, у меня есть плеер FFME для предпросмотра источника, в моем случае с Decklink платы, а... SELECT в PostgreSQL: Can't cast database type record to Int32 Добрый день!
Почему такой запрос не работает -
public string Request()
{
...
|