Четыре года назад на собеседовании я услышал от интервьюера о том, как замечательно паттерн Спецификация помогает справиться с проблемой разрастания репозитория. Я думаю, многие с этим сталкивались, когда количество методов типа getByThisAndThat(…) улетает за десяток, а то и за несколько десятков, и репозиторием становится пользоваться неудобно.

Вдохновившись таким позитивным отзывом, я изучил первоисточник и начал экспериментировать с использованием спецификации как со средством упрощения репозитория.

В этой статье я дам обзор оригинального паттерна, поделюсь своим неоднозначным четырехлетним опытом его применения (с конкретными примерами кода, разумеется) и выскажу свое мнение как о самом паттерне, так и о применении его (на самом деле, его вырожденного варианта) в репозитории.

Оригинальный паттерн: проблематика и виды спецификаций

Лично мне приходилось слышать от других разработчиков о Спецификации только как о средстве, призванном решить проблему разрастания репозитория. Это не единственная проблема, возникающая при работе с репозиториями: я недавно приводил в своем канале свое видение того, что необходимо делать для поддержания качества кода в репозитории на всем сроке жизни проекта.

Так вот, сам Мартин Фаулер, придумавший Спецификацию, практически ничего не говорит о репозиториях и формулирует более широкую проблему, для решения которой предназначена Спецификация.

Поэтому, вначале я считаю правильным коротко рассказать об исходной проблеме и перечислить виды самих спецификаций, которые выделяет Мартин Фаулер. По сути, эта часть статьи - не более, чем мой вольный пересказ первоисточника.

Проблема

Проблема, которую решает Спецификация - это отделение требований, которым должен удовлетворять объект, от самого объекта и от той части системы, которая предъявляет эти требования.

Например: нам нужно решить задачу по проработке маршрутов доставки различных видов грузов морским транспортом. Грузы имеют свои характеристики, такие как размеры, хрупкость, срок хранения. Морские контейнеры имеют свои параметры, а различные маршруты - свои.

Так вот, в разрабатываемой системе требования к грузу, то есть знание о том, какой груз в какой контейнер можно положить, могли бы быть частью самого груза, частью контейнера, или частью модуля, прорабатывающего маршрут.

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

interface SpecificationInterface
{
	public function isSatisfiedBy(Container $container): bool;
}

Метод isSatisfiedBy() обязан давать ответ на вопрос, удовлетворяет ли объект (в данном случае, морской контейнер), требованиям, носителем которых является Спецификация. Давайте посмотрим на конкретные виды Спецификаций, чтобы стало понятнее.

Hard Coded Specification

Самый простой вариант. Просто впишите критерии выборки в метод isSatisfiedBy(). Плюсы: легко и выразительно. Минусы: не гибко.

class MeatStorageSpecification implements SpecificationInterface
{
	public function isSatisfiedBy(Container $container): bool
	{
		return $container->canMaintainTemperatureBelow(-4)
		&& $container->isSanitaryForFood();
	}
}

Как видите, код довольно прост и выразителен: контейнер, пригодный для перевозки мяса, обязан поддерживать отрицательную температуру и соответствовать санитарным нормам.

По сути, спецификация здесь является вариантом паттерна Стратегия: каждый конкретный способ провалидировать объект - это новая стратегия. Кстати, у меня есть статья на Хабре о некоторых паттернах банды 4х, но не о Стратегии. Если интересна Стратегия, то вот мой пост в канале об этом паттерне.

Используя Hard Coded Specification, вы можете писать неограниченное количество спецификаций на все случаи жизни и проверять свои объекты на соответствие какой-то одной спецификации или сразу нескольким, в зависимости от вашей задачи.

Если вы не предполагаете, что у вас возникнет большое количество правил валидации (Спецификаций), то этот вариант вполне неплох. При большом количестве требований, выражаемых через разные Hard Coded спецификации ваш код может начать дублироваться, а в самих спецификациях станет труднее ориентироваться из-за их количества.

Параметрируемая спецификация

В этом варианте в класс спецификации добавляются параметры, которые подвержены изменениям. А реализация метода isSatisfiedBy() опирается на различные комбинации этих параметров. Плюс: некоторая гибкость. Минус: все еще требует создавать классы специального назначения.

class CargoStorageSpecification implements SpecificationInterface
{
    private int $maxTemp;
    private bool $sanitaryForFood;
    
    public function isSatisfiedBy(Container $container): bool
    {
        return $container->canMaintainTemperatureBelow($this->maxTemp)
		&& $container->isSanitaryForFood($this->sanitaryForFood);
    }
}

Такой подход дает больше гибкости, однако при большом количестве параметров ваша спецификация станет громоздкой. Каждый раз при необходимости добавить новый параметр, вам придется думать, добавлять этот параметр в уже существующий класс, или выделять новый.

Композитная спецификация

Чтобы получить максимальную гибкость для наших спецификаций, мы можем воспользоваться паттерном Композит (этот паттерн я тоже рассматривал в своем канале, если интересно).

Плюсы такого подхода:

  • Спецификация получается очень гибкой, не требующей создания большого количества узкоспециализированных классов.

  • Возможность реализовать поддержку логических операций.

Минус: требует создания комплексного фреймворка. Вот упрощенный пример кода:

class TemperatureSpecification implements SpecificationInterface
{
    public function isSatisfiedBy(Container $container): bool
    {
        //...
    }
}

class FoodSanitarySpecification implements SpecificationInterface
{
    public function isSatisfiedBy(Container $container): bool
    {
        //...
    }
}

class CompositeSpecification implements SpecificationInterface
{
    /** @var SpecificationInterface[] */
    private array $nested;
    
    public function __construct(SpecificationInterface ...$nested)
    {
        $this->nested = $nested;
    }
    
    public function isSatisfiedBy(Container $container): bool
    {
        foreach ($this->nested as $spec) {
            if (!$spec->isSatisfiedBy($container)) {
                return false;   
            }
        }
    
        return true;
    }
}

В композитную спецификацию можно добавить поддержку логических операций: ANDOR и более экзотических. Разумеется, все это будет усложнять ваш проект. В первоисточнике предлагается реализовать для этих целей паттерн Интерпретер и, по сути, построить систему, напоминающую Doctrine Query Builder.

Далее в оригинальной статье идет описание еще двух паттернов: Subsumption и Partially fulfilled Specification, которое я опускаю.

Последняя мысль в этой части статьи. Можно последовательно на протяжении жизненного цикла вашего проекта апгрейдить спецификации: начать с хард кода, потом перейти к параметрируемым, а потом и к композитной, в которой некоторые уже созданные вами спецификации станут листьями.

Ну а теперь, во второй части статьи, я поделюсь личным опытом использования Спецификации в своих проектах.

Личный опыт внедрения Спецификации

В этой части статьи я поделюсь своим личным четырёхлетним опытом работы с паттерном Спецификация. Я внедрял его во многих проектах как для нужд борьбы с разрастанием репозитория, так и в качестве архитектурного паттерна, призванного повысить качество кода.

Возможно, во время чтения первой части у вас уже возник вопрос: а как использовать этот паттерн в "репе", если ключевой момент в Спецификации - метод isSatisfiedBy()? В оригинальной статье есть интересный абзац.

If you have to select from a lot of candidates on a database, then performance may well become an issue. You will need to see how to index the candidates in some way to speed up the matching process. How best to index will depend on your domain and on your database technology.

Получается, сам автор паттерна предполагает извлечение из базы значительно большего объема данных, чем конечный набор, удовлетворяющий требованиям Спецификации. Скажу сразу: я не пытался так использовать Спецификацию ни разу.

Если смотреть со стороны репозитория, то мои Спецификации представляли собой по сути DTO, содержащие критерии выборки. То есть, метод isSatisfiedBy() я не реализовывал. Тем не менее есть несколько моментов, которые все-таки отделяют такую вырожденную Спецификацию от обычной DTO.

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

Использование Спецификации в реальной бизнес-задаче

Итак, вот пример реальной задачи, над которой я работаю прямо сейчас. Необходимо реализовать сложную программу лояльности, которая настраивается пользователем в несколько шагов. В числе настроек, например, вид начислений: бонусы, рубли, мили и т. п. Кроме того, необходимо реализовать настройку стартовой раздачи карт лояльности. Выглядит это так: в настройках указывается, по каким критериям клиенты получат карту и какого уровня будет эта карта. Уровни тоже настраиваемые (например: серебро, золото, платина).

Так вот, бизнес попросил реализовать несколько механизмов стартовой раздачи карт. Один вариант - по статусу клиента. Тут все просто. Более сложный вариант: можно указать период в месяцах, и программа должна перебрать все заказы за этот период, посчитать возможные начисления бонусов (а начисления могут быть в разной "валюте" и по разной схеме) и присвоить клиентам уровни лояльности.

Поскольку я приверженец DDD и слоистой архитектуры, то я взялся за эту задачу с описания доменного слоя. Вот несколько классов, которые есть в моей системе:

  • LoyaltyProgram - большой aggregate root, который держит всю бизнес-логику

  • AccumulationStrategy - входит в LoyaltyProgram, реализует паттерн Стратегия. Различные наследники этого класса принимают на вход заказ клиента, а на выход выдают сумму начислений на карту в зависимости от того, в какой "валюте" требуется начислить (рубли, бонусы, мили), и как именно следует вычислять сумму бонусов (есть разные правила).

  • InitialCardIssuing - тоже входит в LoyaltyProgram и содержит в себе параметры и логику той самой стартовой раздачи карт, которая должна произойти после завершения настроек программы лояльности.

Общий принцип такой: когда настройка программы лояльности будет завершена, вылетит соответствующее доменное событие (если вас еще не утомили ссылки на мои же статьи и посты, то вот моя статья на Хабре о доменных событиях)). Обработчик этого события в фоне переберет всех Клиентов и определит, кому какую карту выдать (если заслужил). Поскольку, клиентов может быть очень много, то задача и выполняется в фоне.

Теперь встает вопрос: а как реализовать выборку Клиентов для раздачи карт? В зависимости от того, какие AccumulationStrategy и InitialCardIssuing (напомню и то и то - стратегии) входят в нашу ПЛ, разные выборки гостей получат разные уровни лояльности.

Вот в таких кейсах паттерн Спецификация отлично отвечает на вопрос о том, куда положить знание о критериях выборки каких-либо объектов. Давайте сначала покажу решение на схеме, а потом в коде.

Как видите, знания о том, какие именно гости получат стартовые уровни лояльности, сосредоточено в потомках класса LoyaltyCardSpecification. За производство конкретной спецификации отвечают реализации InitialCardIssuing. Каждая реализация будет порождать свою спецификацию, наполняя ее параметры из своих данных. Ну а LoyaltyProgram как aggregate root предоставляет метод createInitialCardIssuingSpecification(), скрывая за своим интерфейсом все эти тонкости.

namespace App\Application\Command\LoyaltyProgram;

class ExecuteInitialCardIssuing
{
    public function __construct(
        private readonly LoyaltyProgramRepositoryInterface $programRepository,
        private readonly LoyaltyCardRepositoryInterface $cardRepository,
    ) {
    }

    public function handle(ExecuteInitialCardIssuingParams $params): void
    {
        $lp = $this->programRepository->getById($params->programId);
        $specification = $lp->createInitialCardIssuingSpecification();
        $cards = $this->cardRepository->findAllBySpecification($specification);
        foreach ($cards->getElements() as $card) {
            $card->setLevelForced();
        }
        
        $this->cardRepository->save(...$cards->getElements());
    }
}

Несколько комментариев к этому коду:

  • Код был упрощен для наглядности. В реальности генерируется целый SpecificationSet, где каждому уровню лояльности соответствует своя спецификация.

  • $cards->getElements() возвращает коллекцию. Я очень люблю классы-коллекции, вы можете почитать о них в моем посте.

  • $cardRepository->save() поддерживает bulk insert.

  • Если у вас возник вопрос "а если карт тысячи": то да, в реальном проекте есть пагинация по курсору. Здесь я ее убрал, чтобы не загромождать пример.

Как видите, использование спецификации позволило нам отвязаться от конкретных критериев выборки, SQL запросов и тому подобного. Наш код получился очень кратким и лаконичным. Но у этого есть цена.))

Использование спецификаций в репозитории

Вот мы и подошли к тому, с чего начали. В примере выше есть такой код:

$cards = $this->cardRepository->findAllBySpecification($specification);

По сути, задача использования спецификации в репозитории сводится к преобразованию параметров спецификации в SQL запрос или последовательность вызовов методов QueryBuilder нашего фреймворка. И тут все зависит от сложности вашей спецификации.

Если вы используете простую спецификацию, hard coded или параметрируемую с небольшим количеством параметров, то код по интерпретации будет довольно простым. Например:

class LoyaltyCardParamsSpecification extends LoyaltyCardSpecification
{
    public function __construct(
        public string $programId,
        public string $levelId,
        public bool $profileFilled
    ) {
    }
}

class DoctrineLoyaltyCardRepository extends DoctrineRepository implements LoyaltyCardRepositoryInterface
{
    public function __construct(EntityManagerInterface $em)
    {
        parent::__construct($em);
        $this->cardRepository = $this->em->getRepository(LoyaltyCard::class);
    }
    
    private function findAllByParamsSpec(LoyaltyCardParamsSpecification $spec): LoyaltyCardCollection
    {
        $cards = $this->cardRepository->createQueryBuilder('c')
        ->andWhere('c.programId = :programId')
        ->andWhere('c.levelId = :levelId')
        ->andWhere('c.profileFilled = :profileFilled')
        ->getQuery()
        ->setParameters([
            'programId' => $spec->programId,
            'levelId' => $spec->levelId,
            'profileFilled' => $spec->profileFilled
        ])
        ->getResult();
        
        return new LoyaltyCardCollection(...$cards);
    }
}

Как видите, код довольно прост. Мы объединяем все критерии отбора через AND. А теперь подумайте, что будет, если какие-то параметры будут nullable? Вам придется писать if-ы, в которых вы будете проставлять либо равенство либо IS NULL в зависимости от значения параметров спецификации.

Если вам потребуется объединять какие-то параметры через OR, вам придется строить композитную спецификацию. Короче: чем сложнее ваши спецификации, тем объемнее и грязнее код репозитория, который их использует.

Возможно вы обратили внимание, что метод findAllByParamsSpec() приватный. Это от того, что он ищет карты по конкретной реализации спецификации. Публичный же метод выглядит так:

public function findAllBySpecification(LoyaltyCardSpecification $spec): LoyaltyCardCollection
{
    return match ($spec::class) {
        case LoyaltyCardParamsSpecification::class => $this->findAllByParamsSpec($spec),
        //...
        default => throw new \LogicException();
    };
}

Очевидно, что такие решения тоже не добавляют коду красоты.

Заключение и выводы

Я уже 4 года использую паттерн Спецификация в самых разных проектах и в самых разных ситуациях. Мне случалось строить и огромные композитные спецификации, поддерживающие требования сортировки и пагинации. Код репозиториев, работающих с такими спецификациями становился громоздким и малоприятным для чтения.

Так рекомендую ли я этот паттерн? Однозначно. Но только не в качестве средства противодействия разрастанию репозиториев, как в свое время его порекомендовали мне. Этот паттерн прекрасно подходит для ситуаций, когда

  • вам нужно вынести знание о критериях выборки объекта,

  • вариантов таких критериев (реализаций спецификации) может быть несколько,

  • вы хотите отделить логику от низкоуровневой нудятины по поиску данных в базе.

Что касается стремления с помощью спецификации сделать так, чтобы в репе был всего один метод findBySpecification(), то после долгих экспериментов я пришел к выводу, что овчинка выделки не стоит. Нет ничего плохого в методах типа findByDateAndStatus().

Если же в вашем репозитории таких методов стало слишком много, то, возможно, вы плохо делите ответственность. Или попробуйте посмотреть в сторону принципа CQS и поделить свою репу на репозиторий чтения и записи.

На этом все.) Как обычно, спасибо всем, кто дочитал до конца.)

Комментарии (3)


  1. Slparma
    22.07.2025 09:27

    Не буду казаться умным, но такое ощущение что в данном случае паттерн используется ради паттерна. Просто реализовать функцию работы с картами и выборкой из базы гораздо быстрее чем выдумывать архитектуру там где она как будто не нужно. Но я не сеньер. Я лично стараюсь паттерны использовать только по острой необходимости. Тут каждый... как он хочет.


    1. slayervc Автор
      22.07.2025 09:27

      Пример упрощен, чтобы очистить его от лишних деталей и сосредоточиться на самом паттерне. Реальность гораздо страшнее в этой задаче.) Да, вы сами решаете, что вам применять и когда, как инженер, ответственный за задачу.)


  1. AlekseyShibayev
    22.07.2025 09:27

    Прикольно, не знал что это прям паттерн. У нас в java в Spring Data JPA (самая популярная надстройка над ORM), функциональный интерфейс, который вызывается при формировании предиката, и имеющий and, or - так и называется: Specification.

    А что за первоисточник? Черная книга по паттернам корпоративных приложений Фаулера?