InquirerBARMM chief lauds Marcos’ affirmation of Bangsamoro autonomyEl ComercioTrump y Elon Musk contra Zelensky: “Frenar el apoyo satelital es sentenciar a Ucrania a perder la guerra”The Jerusalem PostWest Bank sees four separate clashes between Israelis, Palestinians, including arson attacksFox NewsMurder weapons in Great Wolf Lodge family massacre may offer premeditation evidence: private investigatorDaily MaverickCHAR GPT: Hurtling into the unknown as science fiction becomes factInquirer EntertainmentKris Aquino won’t ‘give oxygen’ to those with ‘irresponsible, uninformed opinions’Bollywood HungamaHas Bollywood crowned Aneet Padda a SUPERSTAR too soon? Shakti Shalini could expose the gap between hype and realityRadio TimesTop 10 Picks of the Day – Tuesday 20 OctoberVarietySundance Winners Blerta Basholli and Myrsini Aristidou Compete in Thessaloniki’s Meet the Neighbors+ Competition (EXCLUSIVE)3DNewsВ Китае заработал самый ёмкий в мире «аккумулятор» на сжатом углекислом газе — на 1 ГВт·чIl Fatto Quotidiano“Ebbi un infarto a Sky, crollai a terra. Rizzica mi salvò e ci provò anche Guido Meda, ma con lui la mia pelle diventava grigia”: così Riccardo MagriniZDF heuteSo haben sich die Mieten in Ihrer Region entwickelt
The Daily Newsstand · Free, Always
Friday, October 16, 2026

Адаптеры вместо switch-case: как мы разложили Angular-форму на 40 полей

Translate

Привет! Я Дмитрий Чепайкин, старший Angular-разработчик в команде Klecks —  платформы для разметки данных AI-центра Т-Банка.

В какой-то момент наш сервис редактирования заказа разросся до 700+ строк: switch-case на десятки полей, конвертеры, валидаторы и сравнение с исходными данными — все в одном файле. Мы применили паттерн адаптеров: каждое поле формы получило свой класс с единым интерфейсом. Расскажу, с какими болями столкнулись, как устроен подход и что получилось в итоге.

Проблема: 700 строк switch-case

Когда форма содержит 5—10 полей, держать всю логику в одном сервисе нормально. Но формы в enterprise-приложениях растут. В нашем случае форма редактирования заказа содержит больше 40 полей: цена, приоритет, теги, настройки перекрытия, GPT-опции, автобан, лимиты, типы устройств и десятки других.

Для каждого поля нужно:

  • инициализировать FormControl;

  • преобразовать значение из DTO в формат формы;

  • преобразовать значение из формы обратно в DTO;

  • настроить валидаторы;

  • сравнить текущее значение с исходным, чтобы отправить только измененные поля.

Вся логика жила в одном EditOrderFormService. С каждой новой фичей сервис обрастал ветками switch-case, хелперами и особыми случаями, тестировать его становилось тяжело, а переиспользовать невозможно.

Для решения подобных задач часто используют паттерн Adapter. Adapter — классический структурный паттерн: прослойка, которая приводит один интерфейс к другому, скрывая детали преобразования. В каноне он «сдруживает» несовместимые API. В более широком смысле это способ инкапсулировать преобразование данных и поведения.Поэтому мы и применили паттерн к полям формы: адаптер знает, как работать с конкретным полем — конвертировать DTO в FormControl и обратно, валидировать и понимать, изменилось ли значение. Сервис формы об этом не думает.

В рамках задачи мы решили вынести каждое поле в отдельный адаптер с общим интерфейсом и оставить сервис оркестратором.

Зачем вообще трогать то, что работает

Формально EditOrderFormService справлялся: заказ открывался, поля сохранялись, валидация отрабатывала. Но по мере роста платформы жить с этим файлом становилось все менее комфортно. 

Нам мешали: 

  • switch-case на каждое поле;

  • сложные тесты;

  • нулевое переиспользование;

  • плохая читаемость.

Типичный фрагмент выглядел так:

private convertField(key: string, dto: OrderOptionsDto): FormField {
    switch (key) {
        case 'price':
            return dto.price ?? null;
        case 'deviceType':
            return deviceTypeForConverting.get(dto.deviceType) ?? null;
        case 'answersOverlap':
            return dto.answersOverlap ?? VOID_TASK_LIMIT;
        case 'gptOptions':
            return dto.gptOptions ?? DEFAULT_GPT_OPTIONS;
        // ... еще 30+ кейсов
    }
}

private deconvertField(key: string, value: FormField, lastDto: OrderOptionsDto): DtoField {
    switch (key) {
        case 'price':
            return value ?? 0;
        case 'deviceType':
            return deviceTypeForDeconverting.get(value) ?? null;
        // ... еще 30+ кейсов
    }
}

В switch case обработка происходит каждый раз на каждое поле и так несколько раз. Для конвертации из DTO, для обратного преобразования, для валидаторов, для сравнения значений. Логика одного поля была размазана по разным методам одного огромного файла.

Сервис целиком покрывался тестами. Чтобы проверить поведение цены, приходилось поднимать контекст на десятки полей. Изолированно протестировать одно поле было практически невозможно.

Нельзя было переиспользовать цену, теги или тип устройства в других формах, например в заказе со скиллами. Без copy-paste перенести логику было нельзя: она зашита внутрь EditOrderFormService. Чтобы понять, как ведет себя одно поле, приходилось прыгать между switch-case-блоками. Импорты в начале файла превратились в список из 30+ типов. Ревью изменений в таком сервисе превращалось в боль.

Проанализировав сервис на предмет рефакторинга, мы сформулировали цель:

  • каждое поле должно инкапсулировать свою логику;

  • поля должны тестироваться изолированно;

  • адаптеры должны переиспользоваться в других формах;

  • сервис должен перестать разрастаться от каждого нового поля;

  • switch-case и «шумные» блоки должны уйти.

Как устроены адаптеры

Чтобы достичь поставленных целей, мы решили использовать адаптеры. 

Идея простая: каждое поле получает свой класс-адаптер с единым интерфейсом OrderFormFieldAdapter. Адаптер отвечает за инициализацию контрола, преобразование из или в DTO, валидацию и сравнение с исходными данными.

export interface OrderFormFieldAdapter {
    /** Имя ключа поля в DTO */
    readonly key: string;
    /** Путь к FormControl в корневом FormGroup */
    readonly path: string[];
    /** Создает FormControl по path, если его еще нет */
    initControl(rootForm: FormGroup, services: AdapterServices): void;
    /** Настройка валидаторов */
    setValidators?(rootForm: FormGroup, services: AdapterServices): void;
    /** Преобразование значения из DTO в значение для формы */
    fromDto(dto: OrderOptionsDto, services: AdapterServices): unknown;
    /** Преобразование значения из формы обратно в DTO. Вернуть null, если изменений нет */
    toDto(rootForm: FormGroup, lastDto: OrderOptionsDto, services: AdapterServices): ToDtoResult | null;
    /** Реакция на обновление сохраненных опций (например, переустановить валидаторы) */
    onStoredOptionsUpdated?(rootForm: FormGroup, lastDto: OrderOptionsDto, services: AdapterServices): void;
}

Каждый метод соответствует одной из обязанностей, которые раньше жили в отдельных switch-case. setValidators и onStoredOptionsUpdated опциональны: большинству простых полей они не нужны. Адаптеры получают зависимости через AdapterServices — контейнер с FormBuilder, ConfigurationService и утилитами. Сами адаптеры при этом остаются обычными TypeScript-классами без @Injectable.

Большинство полей ведут себя одинаково: взять значение из DTO, положить в контрол, при сохранении сравнить с исходным. Для этого есть BaseAdapter:

export abstract class BaseAdapter implements OrderFormFieldAdapter {
    public readonly key: string;
    public readonly path: string[];
    protected readonly deps: Record<string, unknown>;

    protected constructor(config: AdapterConfig) {
        this.key = config.key;
        this.path = config.path;
        this.deps = config.deps ?? {};
    }

    public initControl(rootForm: FormGroup, services: AdapterServices): void {
        ensurePath(services.formBuilder, rootForm, this.path);
    }

    public fromDto(dto: OrderOptionsDto, _services: AdapterServices): unknown {
        return dto[this.key];
    }

    public toDto(rootForm: FormGroup, lastDto: OrderOptionsDto, _services: AdapterServices): ToDtoResult | null {
        const control = getByPath(rootForm, this.path);

        if (!control) {
            return null;
        }

        if (!control.dirty && !control.touched) {
            return null;
        }

        if (isEqualShallow(control.value, lastDto[this.key])) {
            return null;
        }

        return {key: this.key, value: control.value};
    }
}

Сложные поля переопределяют только нужные методы. Простые поля наследуются от BaseAdapter почти без кода:

export class NameAdapter extends BaseAdapter {
    constructor(config: AdapterConfig) { super(config); }
}

Не все поля одинаково простые. Ниже — несколько типичных случаев, с которыми мы столкнулись.

Price — валидация и зависимость от конфигурации. Цена — одно из самых нетривиальных полей: асинхронный валидатор, лимиты из конфигурации, особая семантика null/0 и реакция на обновление исходных данных с сервера. Вся логика цены живет в одном файле. Тестировать ее можно изолированно, не поднимая весь сервис формы.

export class PriceAdapter extends BaseAdapter {
    public fromDto(dto: OrderOptionsDto, _services: AdapterServices): unknown {
        return dto[this.key] ?? null;
    }

    public setValidators(rootForm: FormGroup, services: AdapterServices): void {
        this.updatePriceValidators(rootForm, services);
    }

    public onStoredOptionsUpdated(rootForm: FormGroup, lastDto: OrderOptionsDto, services: AdapterServices): void {
        this.updatePriceValidators(rootForm, services, lastDto[this.key]);

    }

    public toDto(rootForm: FormGroup, lastDto: OrderOptionsDto, _services: AdapterServices): ToDtoResult | null {
        const control = getByPath(rootForm, this.path);

        if (!control || (!control.dirty && !control.touched)) {
            return null;
        }

        const currentPrice = control.value ?? null;
        const lastPrice = lastDto[this.key] ?? null;

        if (currentPrice === lastPrice) {
            return null;
        }
      
        return {key: this.key, value: currentPrice || 0};
    }

    private updatePriceValidators(rootForm: FormGroup, services: AdapterServices, lastPrice?: number): void {
        const control = getByPath(rootForm, this.path);
        const {orderPriceMin, orderPriceMax, isAdmin} = services.configurationService.getConfig();

        control?.setAsyncValidators(
            taskPriceLimitValidator(services.numberPipe, orderPriceMin, orderPriceMax, isAdmin, lastPrice)
        );
    }
}

DeviceType — маппинг enum. DTO хранит один enum, форма работает с другим представлением. Адаптер скрывает маппинг внутри fromDto / toDto. Сервис формы не знает, что где-то происходит преобразование enum.

public fromDto(dto: OrderOptionsDto): unknown {
    const raw = dto[this.key] as OrderDeviceType;
  
    return raw !== undefined ? deviceTypeForConverting[raw] : raw;
}

public toDto(rootForm: FormGroup, lastDto: OrderOptionsDto): ToDtoResult | null {
    const control = getByPath(rootForm, this.path);
  
    if (!control || (!control.dirty && !control.touched)) {
        return null;
    }

    const current = deviceTypeForDeconverting[control.value];
    const last = lastDto[this.key];

    if (current === last) {
        return null;
    }

    return {key: this.key, value: current};
}

Tags — зависимость от другого поля. Теги отправляются, только если тип фильтра операторов — Tags. Путь к зависимому контролу передается через deps в конфигурации. Раньше такая зависимость была спрятана внутри deconvertField. После реализации адаптеров она явно задекларирована в конфиге и выражена в адаптере.

public toDto(rootForm: FormGroup, lastDto: OrderOptionsDto, _services: AdapterServices): ToDtoResult | null {
    const control = getByPath(rootForm, this.path);

    if (!control || (!control.dirty && !control.touched)) {
        return null;
    }

    const filterType = getByPath(rootForm, this.deps.operatorFilterType as string[])?.value;
    const current = control.value;
    const last = lastDto[this.key];

    if (current !== last && filterType === OperatorFilterType.Tags) {
        return {key: this.key, value: current};
    }

    return null;
}

GPT — вложенный объект. GPT-настройки — объект с несколькими полями. Адаптер создает вложенные контролы и сравнивает значения глубоко. Частный случай остается частным: переопределили toDto и initControl — остальной код формы не затронут.

public initControl(rootForm: FormGroup, services: AdapterServices): void {
    ensurePath(services.formBuilder, rootForm, [...this.path, this.deps.temperature as string]);
    ensurePath(services.formBuilder, rootForm, [...this.path, this.deps.modelVersion as string]);

}

public fromDto(dto: OrderOptionsDto, _services: AdapterServices): unknown {
    return dto[this.key] ?? DEFAULT_GPT_OPTIONS;
}

public toDto(rootForm: FormGroup, lastDto: OrderOptionsDto): ToDtoResult | null {
    const group = getByPath(rootForm, this.path);

    if (!group || (!group.dirty && !group.touched)) {
        return null;
    }

    const current = group.value as IGptOptionsDto | null;
    const last = lastDto[this.key] as IGptOptionsDto | null;

    if (isEqualObjects(current, last)) {
        return null;
    }

    if (!last && isEqualObjects(current, DEFAULT_GPT_OPTIONS)) {
        return null;
    }

    return {key: this.key, value: current ? new GptOptionsDto(current) : null};
}

Фабрика и конфигурация

Адаптеры сами по себе еще не собирают форму. Нужна точка, которая по списку полей создает готовые экземпляры. Для этого у нас есть AdapterFactory.

Сначала каждый тип поля описан в enum AdapterType — едином идентификаторе адаптера в системе:

export enum AdapterType {
    NAME = 'name',
    PRICE = 'price',
    DEVICE_TYPE = 'deviceType',
    TAGS = 'tags',
    GPT = 'gpt',
    ASSESSORS_PER_TASK = 'assessorsPerTask',
    // ... остальные поля формы
}

Фабрика держит реестр: какой AdapterType соответствует какому классу.

Фабрика не знает бизнес-логики полей. Ее задача узкая:

  • по типу найти класс адаптера;

  • подставить конфигурацию (key, path, deps);

  • вернуть готовый экземпляр.

export class AdapterFactory {
    private static readonly adapterMap: Record<
        AdapterType, new (config: AdapterConfig) => OrderFormFieldAdapter
        > = {
        [AdapterType.NAME]: NameAdapter,
        [AdapterType.PRICE]: PriceAdapter,
        [AdapterType.DEVICE_TYPE]: DeviceTypeAdapter,
        [AdapterType.TAGS]: TagsAdapter,
        [AdapterType.GPT]: GptAdapter,
        // ... все адаптеры формы
    };

    public static createAdapter(type: AdapterType, config: AdapterConfig): OrderFormFieldAdapter {
        if (!this.adapterMap[type]) {
            throw new Error(Unknown adapter type: ${type});
        }

        if (!config?.key || !config?.path) {
            throw new Error(Invalid adapter config for type ${type});
        }

        return new this.adapterMap[type](config);
    }

    public static createAdapters(factoryConfig: AdapterFactoryConfig): OrderFormFieldAdapter[] {
        return factoryConfig.adapters.map(type => {
            const config = factoryConfig.configs[type];

            if (!config) {
                throw new Error(Missing config for adapter type: ${type});
            }

            return this.createAdapter(type, config);
        });
    }
}

createAdapter создает один адаптер. createAdapters — сразу массив для формы. Если тип неизвестен или в конфиге нет записи, получаем явную ошибку на этапе сборки формы, а не «тихий» пропуск поля.

Конфигурация конкретной формы. Сама форма описывает, какие адаптеры ей нужны и с какими параметрами. key — имя поля в DTO, path — путь к контролу в FormGroup (с учетом вкладок и панелей), deps — пути к зависимым контролам:

export const EDIT_ORDER_FORM_CONFIG: AdapterFactoryConfig = {
    adapters: [
        AdapterType.NAME,
        AdapterType.PRICE,
        AdapterType.DEVICE_TYPE,
        AdapterType.TAGS,
        AdapterType.GPT,
        // ... остальные поля
    ],

    configs: {
        [AdapterType.NAME]: {key: 'name', path: ['name']},
        [AdapterType.PRICE]: {key: 'price', path: ['price']},
        [AdapterType.TAGS]: {
            key: 'tags',
            path: ['tags'],
            deps: {operatorFilterType: ['operatorFilterType']}
        },
        [AdapterType.GPT]: {
            key: 'gptOptions',
            path: ['gptOptions'],
            deps: {temperature: 'temperature', modelVersion: 'modelVersion'}
        },
    }
};

Чтобы не тащить плоский список из 40 типов, поля сгруппированы по секциям UI:

export enum AdapterGroup {
    DETAILS = 'details',
    TASKS = 'tasks',
    SETTINGS = 'settings',
    QUALITY_CONTROL = 'qualityControl',
    TRAINING = 'training',
    // ...
}

export const ADAPTER_GROUPS: Record<AdapterGroup, AdapterType[]> = {
    [AdapterGroup.DETAILS]: [AdapterType.NAME],
    [AdapterGroup.TASKS]: [
        AdapterType.DEVICE_TYPE,
        AdapterType.PRICE,
        AdapterType.TASK_GROUP_OPTIONS,
        // ...
    ],
    [AdapterGroup.SETTINGS]: [
        AdapterType.TAGS,
        AdapterType.GPT,
        // ...
    ],
    // ...
};

export function getAllAdapters(): AdapterType[] {
    return Object.values(ADAPTER_GROUPS).flat();
}

В сервисе формы это сводится к одному вызову:

this.adapters = AdapterFactory.createAdapters({
    adapters: getAllAdapters(),
    configs: EDIT_ORDER_FORM_CONFIG

});

Без фабрики сервис снова начал бы знать про конкретные классы: new PriceAdapter(...), new TagsAdapter(...) и так далее. Мы бы просто перенесли switch-case из convertField в конструктор.

Фабрика разрывает эту связь:

  • сервис зависит только от OrderFormFieldAdapter[];

  • конкретные классы зарегистрированы в одном месте — adapterMap;

  • состав формы задается конфигом, а не хардкодом в сервисе;

  • другую форму можно собрать из тех же адаптеров с другим набором AdapterType.

Добавление нового поля в итоге выглядит так:

  • создать файл адаптера;

  • добавить значение в AdapterType;

  • зарегистрировать класс в adapterMap фабрики;

  • описать конфиг в EDIT_ORDER_FORM_CONFIG;

  • положить тип в нужную AdapterGroup.

Сервис формы при этом не меняется.

Как изменился код приложения

Раньше сервис знал все про каждое поле. Теперь он работает только с интерфейсом.

Было: 700+ строк, несколько switch-case, десятки импортов, невозможность тестировать поля отдельно.

@Injectable()
export class EditOrderFormService implements OnDestroy {
    public formGroup: FormGroup;
    private readonly adapters: OrderFormFieldAdapter[];
    private readonly services: AdapterServices;
    private lastDto: OrderOptionsDto;

    constructor(
        private readonly formBuilder: FormBuilder,
        private readonly configurationService: ConfigurationService,
        private readonly numberPipe: TuiFormatNumberPipe,

    ) {
        this.services = {formBuilder, configurationService, numberPipe, getControl: getControlByPath};
        this.adapters = AdapterFactory.createAdapters(EDIT_ORDER_FORM_CONFIG);
        this.formGroup = this.formBuilder.group({});
      
        this.adapters.forEach(a => a.initControl(this.formGroup, this.services));
        this.adapters.forEach(a => a.setValidators?.(this.formGroup, this.services));

    }

    public loadOrderOptions(dto: OrderOptionsDto): void {
        this.lastDto = dto;

        this.adapters.forEach(a => {
            const value = a.fromDto(dto, this.services);
            getControlByPath(this.formGroup, a.path)?.setValue(value, {emitEvent: false});
        });

        this.adapters.forEach(a => a.onStoredOptionsUpdated?.(this.formGroup, dto, this.services));
    }

    public collectChanges(): Partial<IOrderOptionsDto> {
        return this.adapters
            .map(a => a.toDto(this.formGroup, this.lastDto, this.services))
            .filter(Boolean)
            .reduce((acc, {key, value}) => ({...acc, [key]: value}), {});
    }
}

Сервис больше не выбирает поведение по имени поля. Он итерирует адаптеры и вызывает методы интерфейса. Полезная деталь: декомпозиция с 700 до ~80 строк — следствие подхода, а не отдельная цель. Мы выделили абстракцию, и сервис похудел сам.

Что дает внедрение адаптеров на практике:
1. Инкапсуляцию. Чтобы понять поведение цены, открываем price.adapter.ts. Не нужно искать конвертацию, валидацию и сравнение по разным методам сервиса.

2. Тестируемость. Тест адаптера — unit-тест одного класса. Не нужно мокать 40 контролов, чтобы проверить одно поле. Количество тестов выросло, сложность setup — упала.

3. Переиспользование. Адаптеры лежат в shared/form-adapters. Другая форма может взять PriceAdapter или TagsAdapter через свою конфигурацию — без copy-paste.

4. Расширяемость. Новое поле = новый файл адаптера + строка в enum + строка в фабрике + строка в конфиге.

5. Явные зависимости между полями. Связь «теги зависят от типа фильтра» теперь видна в deps конфигурации, а не спрятана в ветке switch-case.

Что получилось: цифры и trade-offs

Мы убрали switch-case, размазанную логику и copy-paste. Ниже собраны результаты решения, его плюсы  и минусы, которыми пришлось заплатить.

Было

Стало

Сервис формы

~700 строк, несколько switch-case

~80 строк, циклы по адаптерам

Логика поля

размазана по методам сервиса

один файл-адаптер

Тесты

сервис целиком

изолированные spec на сложные адаптеры

Переиспользование

copy-paste

shared/form-adapters + конфиг формы

Добавление поля

правки в 4—5 местах

новый адаптер + конфиг

Плюсы, которые мы почувствовали:

  • логика поля стала локальной и читаемой;

  • тесты перестали требовать тяжелый setup на всю форму;

  • адаптеры можно переиспользовать в других частях приложения;

  • сервис перестал быть bottleneck при добавлении полей.

Минусы и компромиссы, о которых стоит знать:

  • файлов стало больше — на каждое поле свой адаптер и часто свой spec;

  • простые поля выглядят «тонкими» классами поверх BaseAdapter — это плата за единообразие;

  • нужна дисциплина: новая логика поля должна жить в адаптере, а не возвращаться в сервис;

  • фабрика и enum — еще одна точка регистрации, о которой нельзя забывать.

Для нас это оказалась разумная цена за то, что форма перестала быть монолитом.

Мы не искали «красивый рефакторинг ради рефакторинга». Цель была инкапсулировать ответственность за каждое поле, сделать поля тестируемыми,  получить возможность переиспользовать адаптеры и убрать switch-case и шум из сервиса формы.

Паттерн адаптеров позволил решить эти задачи. Самый заметный эффект — EditOrderFormService перестал разрастаться. Но со временем стало понятно, что главный результат вообще не в размере файла. Мы изменили точку опоры.

Раньше сервис был хранилищем всей логики полей:

  • конвертация;

  • валидация;

  • сравнение;

  • особые случаи.

Теперь сервис — оркестратор. Ему достаточно массива адаптеров с общим интерфейсом. А каждое поле живет своей жизнью.

Это особенно хорошо работает, когда:

1) форма содержит много полей (15+);
2) логика полей различается;
3) одни и те же поля встречаются в нескольких формах;
4) поля нужно тестировать изолированно.

Конечно, компромиссы никуда не исчезли. У нас по-прежнему больше файлов, и за регистрацией адаптеров нужно следить. Но для нас это разумная цена за то, что поле формы стало самостоятельной единицей, а не веткой в switch-case.

Если у вас похожая ситуация с раздувшимися Angular-формами — как у вас устроена работа с полями? Держите все в одном сервисе, режете по feature-сервисам или уже уходите в адаптеры или стратегии? Поделитесь опытом в комментариях.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.