> Как архитектурно передавать на фронт сложную структуру с вложенными полями и массивами (Go)

Уровень: senior · Роль: backend · Категория: Технические вопросы

Компании: Wildberries

Стек: Go

> Пример ответа

Короткий ответ

Для передачи сложной вложенной структуры на фронт используем JSON с явно описанными DTO (data transfer objects) на стороне Go. Ключевые решения: строгая типизация через struct с json-тегами, использование omitempty для опциональных полей, избегание map[string]interface{} для вложенных данных. Для массивов - слайсы структур, для вложенности - указатели или значения структур. При необходимости - кастомная маршализация через json.Marshaler для контроля формата. Важно не отдавать доменные модели напрямую, а формировать отдельный слой представления.

Подробное объяснение

Архитектурно задача сводится к трём уровням: доменная модель, DTO и транспортный формат. Доменная модель содержит бизнес-логику и внутренние зависимости - её нельзя напрямую сериализовать, так как это раскрывает внутренности и создаёт жёсткую связку между слоями. DTO - это плоские структуры, которые описывают только то, что нужно отдать клиенту. Они могут агрегировать данные из нескольких доменных объектов, переименовывать поля, скрывать чувствительные данные.

Для вложенных структур в Go используем композицию структур: поле типа ChildStruct или []ChildStruct. JSON-теги задают имена полей в ответе. Для опциональных полей - указатели (*string) или omitempty. Для полиморфных структур - интерфейсы с кастомной маршализацией, но это усложняет поддержку, поэтому лучше избегать, если возможно.

Важный момент - версионирование API. Если структура сложная и будет эволюционировать, стоит сразу закладывать версию в URL или заголовок, чтобы менять DTO без поломки старых клиентов. Также стоит продумать пагинацию для массивов: не отдавать все элементы разом, а оборачивать в объект с items и total.

Для производительности: если структура очень большая, можно использовать json.Encoder с буферизацией, а не json.Marshal - это снижает пиковое потребление памяти. Для стриминга больших массивов - json.Decoder на стороне клиента, но это уже вопрос фронта.

На практике

На практике процесс выглядит так: сначала проектируем контракт - что именно нужно фронту. Затем создаём DTO-структуры, которые повторяют этот контракт. Маппинг из доменных моделей в DTO делаем вручную через функции-конструкторы или через библиотеки типа mapstructure, но ручной маппинг предпочтительнее - он явный и легко тестируется.

Для вложенных массивов часто возникает вопрос: отдавать ли пустой массив или null. Решаем через omitempty - если слайс пустой, поле просто не появится в JSON. Но это trade-off: фронт должен обрабатывать отсутствие поля. Альтернатива - всегда инициализировать слайс пустым значением, тогда в JSON будет [].

Ещё практический момент - обработка ошибок валидации. Если структура сложная, лучше не отдавать ошибку с кодом 400 и текстом, а структурировать: {"errors": {"field": "message"}}. Это тоже часть контракта.

Пример кода

GO
// DTO для ответа
type UserResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email,omitempty"`
Profile *ProfileDTO `json:"profile,omitempty"`
Roles []RoleDTO `json:"roles"`
Settings map[string]string `json:"settings,omitempty"`
}
type ProfileDTO struct {
AvatarURL string `json:"avatar_url"`
Bio string `json:"bio,omitempty"`
}
type RoleDTO struct {
Code string `json:"code"`
Name string `json:"name"`
}
// Маппинг из доменной модели
func NewUserResponse(u *domain.User) *UserResponse {
resp := &UserResponse{
ID: u.ID.String(),
Name: u.Name,
Roles: make([]RoleDTO, 0, len(u.Roles)),
}
if u.Email != nil {
resp.Email = *u.Email
}
if u.Profile != nil {
resp.Profile = &ProfileDTO{
AvatarURL: u.Profile.AvatarURL,
Bio: u.Profile.Bio,
}
}
for _, r := range u.Roles {
resp.Roles = append(resp.Roles, RoleDTO{
Code: r.Code,
Name: r.Name,
})
}
return resp
}
// В хендлере
func GetUserHandler(w http.ResponseWriter, r *http.Request) {
user, err := srv.GetUser(r.Context(), id)
if err != nil {
writeError(w, err)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(NewUserResponse(user))
}

Как отвечать на собеседовании

Начните с главного: JSON + DTO, разделение доменной модели и представления. Затем объясните, как решаете вложенность - композиция структур, указатели для опциональности. Упомяните trade-off между omitempty и явными null. Если спросят про производительность - скажите про json.Encoder и стриминг. Если спросят про версионирование - предложите версию в URL или заголовке. Покажите, что думаете о контракте с фронтом: пагинация, структура ошибок, стабильность полей. Не углубляйтесь в детали реализации, если не просят - лучше показать понимание архитектурных принципов.

Что проверяет интервьюер

Проверяется понимание разделения ответственности между слоями: домен, DTO, транспорт. Умение проектировать контракт API, а не просто "отдать JSON". Понимание особенностей Go: как работают json-теги, указатели, omitempty, поведение слайсов при сериализации. Также оценивается способность видеть trade-off: например, почему не использовать map[string]interface{} - теряется типизация, сложно поддерживать, нет автокомплита. И ещё - умение думать о клиенте: что фронту удобно потреблять, как обрабатывать отсутствующие поля.

Типичные ошибки

  • Отдавать доменные модели напрямую - раскрытие внутренностей, жёсткая связка.
  • Использовать map[string]interface{} для вложенных данных - теряется типизация, сложно рефакторить.
  • Не учитывать omitempty - фронт получает null вместо отсутствия поля, или наоборот.
  • Игнорировать пагинацию для больших массивов - отдавать всё разом, что убивает производительность.
  • Не продумывать версионирование - потом ломать клиентов при изменении структуры.
  • Забывать про структуру ошибок - фронт не может понять, что именно не так.
  • Использовать json.Marshal для больших ответов вместо json.Encoder - лишнее потребление памяти.

> ГОТОВЫ К СЛЕДУЮЩЕМУ СОБЕСЕДОВАНИЮ?

Запустите тренировочную сессию с ИИ и получите детальную обратную связь, чтобы увереннее проходить реальные интервью