Сущности и DTO
Модель хранения и публичный ответ часто совпадают в начале проекта, но развиваются по разным причинам. Редактору может понадобиться внутренняя заметка курса, которую посетитель не должен получать. Если handler сериализует всю строку базы без отдельного выбора полей, внутреннее изменение незаметно становится внешним. В этом уроке разделим сущность и DTO ответа.
Начальное состояние — приложение с PostgreSQL из прошлого урока. В lesson-15/after таблица получает internal_notes, repository возвращает CourseEntity, а endpoints формируют CourseResponse. Старые ID, названия, темы и числа сохраняются. SQL и C# при подготовке не выполнялись; результаты схемы и ответа ниже являются ожидаемыми после осознанного ручного применения в отдельной песочнице.
Служебное поле существует только в хранилище
Новый SQL-файл database/lesson-15-add-notes.sql применяется после создания таблицы прошлого урока:
ALTER TABLE courses ADD COLUMN internal_notes text NOT NULL DEFAULT '';
UPDATE courses SET internal_notes='Черновик редактора' WHERE id='javascript';
Поле имеет непустое определение NOT NULL, но само значение по умолчанию — пустая строка. Это значит, что каждая строка имеет определённую заметку, хотя заметка может не содержать текста. Для JavaScript установлено учебное сообщение «Черновик редактора», чтобы изменение было заметно в данных. Этот текст не является реальной информацией ProfessorWeb.
Если продолжаете уже подготовленную базу 14, выполняется только новый файл. Если создаёте совершенно свежую песочницу для конечного снимка 15 или 16, порядок — сначала create-and-seed.sql, затем этот шаг. Снимки не запускают миграции автоматически, поэтому наличие нового C#-файла ещё не создаёт столбец. Повторное применение ADD COLUMN к уже изменённой таблице даст ошибку, и сценарий не следует молча продолжать.
Разделение изменений полезно и для понимания совместимости. Сначала появляется допустимое служебное поле хранения, затем код начинает читать его. Если новая программа запросит internal_notes раньше изменения схемы, repository получит ошибку SQL, а не пустую заметку. Настоящее развёртывание требует собственного порядка совместимых шагов; здесь мы лишь фиксируем учебное состояние.
Две формы одного курса
Полный файл Models/CourseEntity.cs содержит сущность, DTO и отображение:
namespace CatalogApi;
public sealed record CourseEntity(string Id, string Title, string Topic,
int Lessons, string InternalNotes);
public sealed record CourseResponse(string Id, string Title, string Topic, int Lessons);
public static class CourseMapping
{
public static CourseResponse ToResponse(CourseEntity entity) =>
new(entity.Id, entity.Title, entity.Topic, entity.Lessons);
}
CourseEntity отражает выбранные столбцы хранилища, включая служебный текст. CourseResponse содержит только четыре публичных поля. Метод ToResponse явно выбирает каждое из них. Поэтому добавление ещё одного свойства в сущность не обязано менять JSON: чтобы выдать его клиенту, разработчик должен отдельно изменить DTO и mapping.
DTO означает объект передачи данных. Он не обязательно должен быть большим классом с сотнями правил; в этом случае это маленький record, задающий внешнюю форму. Сущность также не обязана быть объектом EF Core: наш repository использует Npgsql и вручную создаёт значение из reader. Название «entity» здесь указывает на внутреннюю модель записи, а не на подключённую ORM.
Входная команда CourseInput остаётся отдельной. Клиент не отправляет internalNotes и не назначает ID. Не нужно использовать один тип одновременно для строки базы, публичного GET и POST создания только ради сокращения числа файлов. У этих границ различаются допустимые поля и причины изменения.
Repository читает, endpoint выбирает представление
Интерфейс repository теперь возвращает CourseEntity, а SQL перечисляет пять столбцов. При чтении последним значением берётся reader.GetString(4). Номер связан с явным порядком SELECT. Поэтому использование SELECT * было бы менее прозрачным: после изменений схемы связь столбцов с индексами сложнее оценить чтением.
Handler поиска обновлён следующим образом:
private static async Task<Results<Ok<CourseResponse>, ProblemHttpResult>> FindCourse(
string id, ICatalogRepository repository, CancellationToken token)
{
var course = await repository.FindAsync(id, token);
return course is null ? MissingCourse(id) : TypedResults.Ok(CourseMapping.ToResponse(course));
}
Сначала выполняется поиск внутренней модели, затем успешная запись преобразуется в разрешённый ответ. Наличие заметки не влияет на предметный 404. Ошибка чтения базы тоже не становится ошибкой mapping: эти этапы имеют разные причины отказа, даже если общий публичный формат неожиданного сбоя одинаков.
Список также вызывает Select(CourseMapping.ToResponse) перед фильтром и сериализацией. В текущем наборе фильтр использует только публичные поля — название и число уроков. Внешний query не позволяет искать служебную заметку, поэтому мы не создаём косвенный канал её раскрытия через результат поиска.
Совместимость публичного ответа
GET JavaScript ожидаемо сохраняет прежнее представление:
{"id":"javascript","title":"Современный JavaScript","topic":"frontend","lessons":20}
В нём нет internalNotes. Сама заметка существует в таблице и внутренней модели, но выбор DTO не включает её. Клиент, который уже читал четыре поля, может продолжить делать это после изменения хранилища. Так внутренний рост проекта не требует переселять URL или заставлять всех клиентов изучать ненужные сведения.
Однако DTO не является аутентификацией или универсальной политикой доступа. Он ограничивает форму одного ответа. Если другой endpoint вернёт сущность напрямую, служебное поле снова может стать видимым. Поэтому важно применять договорённость последовательно и читать каждую публичную границу. Типы помогают видеть форму, но не гарантируют, что разработчик всегда выбрал правильный тип.
Неизвестные поля входного JSON также не стоит воспринимать как разрешение их сохранять. Стандартная привязка может не использовать лишнее свойство, но безопасность текущей операции возникает из явного списка CourseInput и построения CourseDraft. Будущий POST не должен передавать произвольный пользовательский словарь прямо в SQL UPDATE.
Правило длины названия согласуется с хранилищем: C#-validator считает Unicode-скаляры, PostgreSQL char_length — символы UTF-8 строки. Пользовательские графемы здесь не являются единицей. Ограничение следует сохранять одинаковым на обеих границах, иначе корректная по HTTP команда может неожиданно нарушить SQL CHECK.
Документация Microsoft по Minimal API responses объясняет сериализуемый результат и типы ответов. Выбор четырёх полей и служебной заметки является собственным контрактом нашего каталога. Это существенная граница: framework умеет выдать объект, но не знает, какие свойства продукта должны стать публичными.
Отображение DTO выполняется на сервере до отправки данных. Скрыть служебное поле только стилями браузерной страницы недостаточно: оно уже окажется в полученном JSON и станет доступно любому клиенту. Явный CourseResponse предотвращает включение поля в этот ответ ещё на границе программы. Это ограничение выдаваемой формы, а не косметическая настройка интерфейса.
Обратное отображение входной команды также должно быть явным. Создание сущности из всех присланных свойств привело бы к возможности менять поля, которые форма посетителя даже не показывает. В следующем уроке repository получает проверенный CourseDraft, а внутреннюю заметку сохраняет по серверному правилу.
После урока данные хранения и представление клиента разделены явным отображением. В следующем уроке используем входной draft для создания и замены записи, сохраняя ID в пути и служебную заметку вне пользовательской команды.