Введение

В предыдущей статье "Адаптированный паттерн Command с использованием Dependency Injection", я описывал как инкапсуляция логики приложений в отдельные объекты-функции позволяет получить преимущества в архитектуре приложений.

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

Эта статья пытается исправиль допущенную автором ошибку.

Статья является дополнением к предыдущей.

Примеры кода и демо проект

Все примеры в статье и демо проект даны на C#. Демо проект показывает архитектуру приложения состоящую исключительно из Function Object с использованием DI.

https://github.com/abaula/guess_number

Демо проект может служить пособием для изучения и экспериментов.

Краткая справка о термине

Термин Функция-объект (Function Object, или Functor) в контексте объектно-ориентированного программирования появился как естественное развитие концепции функций как объектов из функционального программирования и возможностей ООП.

Концепция функциональных объектов присутствует в теории объектно-ориентированной и функциональной парадигм программирования задолго до формализации конкретных паттернов в таких трудах, как «Gang of Four» (1994), где этот паттерн в классическом понимании явно не выделялся, но был широко применяем в практике программирования.

Идея функции-объекта возникла главным образом с появлением и развитием языков программирования, поддерживающих перегрузку операторов и возможность создавать объекты, которые можно вызывать как функции. Одним из первых таких языков был C++, разработанный Бьёрном Страуструпом в 1980-х годах, в котором появилась возможность перегружать оператор вызова (operator()) для создания функций-объектов. Это позволило инкапсулировать состояние и поведение в одном объекте, который можно использовать как функцию.

Таким образом, термин и концепция Function Object не имеют единственного изобретателя или конкретной даты введения — это результат развития языка C++ и идей функционального программирования, где функции рассматриваются как объекты первого класса. Эти идеи развивались в 1980-х и 1990-х годах в процессе эволюции объектно-ориентированных языков.

Кратко история:

  • Истоки в функциональном программировании и концепции функций как сущностей (lambda calculus, 1936; язык LISP, 1950-е).

  • Эволюция ООП с развитием языков Simula, Smalltalk, Objective-C и особенно C++.

  • Введение в C++ возможности перегрузки оператора вызова функции позволило создавать функцию-объекты, которые стали широко использоваться.

  • Термин "Function Object" как такой стал употребляться в сообществе разработчиков C++ и смежных языков с конца 1980-х — начала 1990-х годов.

Это был естественный шаг эволюции языков, обобщающий функциональные и объектные концепции.

Преимущества реализации бизнес логики приложения с использованием Function Object

Использование Function Object для реализации бизнес-логики приложения дает значительные преимущества:

Разделение ответственности и повторное использование

Function Object позволяет выносить бизнес-логику в отдельные сущности, которые легко переиспользовать в разных частях приложения и даже в других проектах. Такой подход способствует соблюдению принципа единственной ответственности (SRP) и предотвращает дублирование логики.

Улучшение тестируемости

Function Object — это изолированные сущности, их просто тестировать отдельно от остального приложения. Из-за отсутствия глобального состояния и явной передачи зависимостей тесты становятся более простыми и надежными.

Повышение читаемости и структуры кода

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

Гибкость и расширяемость

Function Object легко комбинировать, декорировать или изменять через композицию, что позволяет просто реализовывать сложные бизнес-процессы и сценарии без усложнения архитектуры.

Инкапсуляция состояния и зависимостей

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

Все эти преимущества особенно проявляются в сервисных и распределённых приложениях с интенсивным повторным использованием бизнес-кода.

Примеры Function Object в реальных C# проектах

В реальных C# проектах Function Object часто реализуются через классы, содержащие бизнес-логику, и используются для инкапсуляции отдельных операций, что упрощает повторное использование и тестирование, например, обработка платежа, проверка пользователя или запуск сложной бизнес-функции как объекта.

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

Реализация бизнес-операции через функциональный объект

public interface IBusinessOperation 
{     
	void Execute(); 
} 

// Function Object: каждая операция инкапсулируется в отдельном классе 
public class PayInvoiceOperation : IBusinessOperation 
{     
	private readonly InvoiceProvider _invoiceProvider;     
	public PayInvoiceOperation(InvoiceProvider invoiceProvider)     
	{         
		_invoiceProvider = invoiceProvider;
	}     
	
	public void Execute()     
	{         
		// Логика оплаты счета
	    _invoiceProvider.Pay();
	    // Обработка ошибок, логирование и т.д.
	    // ...
	} 
}

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

Использование делегатов и лямбда-выражений

Function Object в C# зачастую реализуют через делегаты, позволяя инкапсулировать не только методы класса, но и любые функции, подходящие по сигнатуре:

public interface IBusinessOperation 
{     
	void Execute(); 
}

public class FunctionObject : IBusinessOperation
{
	private readonly Action _action;     
	
	public FunctionObject(Action action)     
	{         
		_action = action;     
	}     
	
	public void Execute() => _action(); 
}

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

Мой опыт применения Function Object в реальных проектах

Мой последний проект состоял из более чем 250 микросервисов, и включал в себя такие группы функций как ETL, Search, RAG, и только в части из них были использованы Function Object. Если бы можно было начать проект сначала, то я бы предпочёл реализовать все модули без исключения на Function Object.

Можно спорить хорошо это или не очень, но на мой субъективный взгляд удалось бы сэкономить 10-15% времени, без проблем покрыть код проекта, хотя бы самые критические части, модульными тестами.

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


  1. BeceJlb4ak
    08.10.2025 10:53

    Зачем нужна обертка над лямбдой - только ради инжекта?


    1. Naf2000
      08.10.2025 10:53

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

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


    1. antonb73 Автор
      08.10.2025 10:53

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

      Если в вашем приложении нет хранимых состояний, вам не нужны объекты, достаточно будет голых функций, и конечно же ФП лучше подойдёт в этом случае.

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

      Наверное не стоит повторять, что не существует универсальных решений. Function Object также не является серебрянной пулей.


  1. Dhwtj
    08.10.2025 10:53

    Откуда вы эти антипатерны достали? Хватит по чуланам шариться.

    Даже у Шарп, который в основе ООП умеет с функциями как

    First-class citizen — функция может всё, что и другие значения:

    • Присвоить переменной

    • Передать в функцию

    • Вернуть из функции

    • Создать "на лету"

    И вместо вашего кода такие варианты

    // Вместо интерфейса делегаты
    Action ExecuteOperation = () => invoiceProvider.Pay();
    Func<Task<Result>> AsyncOperation = async () => await ProcessPayment();
    
    //Railway-oriented (как у Влашина):
    var result = await GetInvoice(id)
        .Bind(ValidateInvoice)
        .Map(CalculateDiscount)
        .Bind(ProcessPayment)
        .Match(
            onSuccess: inv => Ok(inv),
            onFailure: err => BadRequest(err)
        );
    
    //3. С discriminated unions (через OneOf или свои):
    public OneOf<Success, ValidationError, PaymentError> PayInvoice(int id) =>
        GetInvoice(id) switch {
            NotFound => new ValidationError("Invoice not found"),
            Invoice inv when !inv.IsValid => new ValidationError("Invalid"),
            Invoice inv => ProcessPayment(inv)
        };
    
    //Новый C# 12 с collection expressions
    var operations = [
        () => invoiceProvider.Pay(),
        () => logger.Log("Paid"),
        () => notifier.Send()
    ];
    
    //Source generators для pipeline
    [Pipeline]
    partial class PaymentPipeline {
        Step1 ValidateUser(Request r) => ...
        Step2 CheckBalance(Step1 s) => ...
        Step3 ProcessPayment(Step2 s) => ...
    }


    1. antonb73 Автор
      08.10.2025 10:53

      Честно говоря, имел бы возможность вас забанить, то с удовольствием сделал бы это. Не люблю хамов любого пошива.

      Ещё не люблю хитрецов подменяющих понятия. В ваших примерах это не C# умеет с функциями так, а это конкретные типы имеют соответствующие методы, а последний пример, это вообще отдельная библиотека которая занимается кодогенерацией.

      Прежде чем писать подумайте в чём ценность вашего комментария.


      1. Cregennan
        08.10.2025 10:53

        Странный аргумент "это не C# умеет с функциями так".
        => - конструкция языка
        return switch Invoice inv when ... => - конструкция языка
        [() => return notifier.Send()] - конструкция языка

        Про "конкретные типы имеют соответствующие методы" - у вас FunctionObject.Execute это конечно же не тип с методом?

        Переименуйте PayInvoiceOperation в PayInvoiceService, Invoke в PayAsync, получите обычный "сервис" которые прямо сейчас пишут в любых проектах и не только на дотнете.


        1. antonb73 Автор
          08.10.2025 10:53

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


    1. VanKrock
      08.10.2025 10:53

      Выглядит отвратительно же, как это читать вообще, вместо нормальной всем понятной логики сплошной бойлерплейт


  1. eeeeeeeeeeee
    08.10.2025 10:53

    Я прочитал и предыдущую статью, и эту, и комменты частично. Долго медитировал на счет корректности паттерна. С одной стороны, раскидать логику по разным юзкейсам - это идея здравая. С другой - чувствуется, что что-то не то... И в общем я пришел к выводу, что в статье просто описывается принип SRP: https://habr.com/ru/articles/454290/

    Но это не функциональный объект, потому что:

    • функциональный объект в C# уже есть, это - делегат

    • таскать внешние зависимости из DI - это в общем-то антипаттерн для ФП; функции должны быть чистыми, иначе шибко их не закомбинируешь

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

    upd

    Мой последний проект состоял из более чем 250 микросервисов, и включал в себя такие группы функций как ETL, Search, RAG, и только в части из них были использованы Function Object

    Подтверждаю. В микросервисах может оказаться очень кайфово просто раскидывать по юзкейсам код. Там какой-нибудь MediatR + Minimal Api или FastEndpoints - и в путь. Нет смысла особо что-то инженерить, т.к. архитектурные границы уже были проведены на уровне сервисов


    1. antonb73 Автор
      08.10.2025 10:53

      Но это не функциональный объект, потому что:

      функциональный объект в C# уже есть, это - делегат

      Тульский пряник, это не пряник, потому что уже есть мятный пряник.

      таскать внешние зависимости из DI - это в общем-то антипаттерн для ФП; функции должны быть чистыми, иначе шибко их не закомбинируешь

      ФП ? Кто то говорил о ФП?


      1. eeeeeeeeeeee
        08.10.2025 10:53

        ФП ? Кто то говорил о ФП?

        Ты сам же :D

        Термин Функция-объект (Function Object, или Functor) в контексте объектно-ориентированного программирования появился как естественное развитие концепции функций как объектов из функционального программирования

        Такой подход часто используется, например, для событий, коллбеков или обработки команд из UI

        Удачи юзнуть "функтор" с зависимостью от EF Core как коллбек на событие, допустим

        ---

        Тульский пряник, это не пряник, потому что уже есть мятный пряник.

        Зачем изобретать тульский пряник на базе мятного пряника?

        ---

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


        1. antonb73 Автор
          08.10.2025 10:53

          https://habr.com/ru/articles/954516/#comment_28943544

          надеюсь, что этот комментарий снимет недопонимание


          1. eeeeeeeeeeee
            08.10.2025 10:53

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

            А если речь идет о том, чтобы полностью конструировать бизнес-логику из таких "объектов-функций", то в таком случае речь идет больше об уходе от ООП, чем о его паттернах. Что-то отдаленно напоминающее процедурное программирование получается ( просто пытаюсь сформулировать, как правильно называется такой подход; если честно, не знаю :D )


  1. VanKrock
    08.10.2025 10:53

    Идея хорошая, мы тоже к такой пришли, но вот реализация как тут совсем не гибкая, нет особого смысла разделять Command и Query, к этому даже в MediatR пришли, не хотите ничего возвращать, верните Unit

    public interface IAction<in TRequest, TResponse>
    {
        Task<TResponse> Execute(TRequest request, CancellationToken cancellationToken)
    }
    
    public class GetUserAction(AppDbContext dbContext, IMapper mapper): IAction<Guid, UserDto>
    {
        public virtual Task<UserDto> Execute(Guid request, CancellationToken cancellationToken)
        {
             return dbContext.Users
                 .Where(u => u.Id == request)
                 .ProjectTo<UserDto>(mapper.ConfigurationProvider)
                 .FirstOrDefaultAsync(cancellationToken) ?? throw new NotFoundException("User not found")
        }
    }
    

    Нет смысла инжектить его потом по интерфейсу, для тестов метод можно сделать virtual тогда бизнес логику можно мокать

    Так же мы у себя ввели правило, что мы не вызываем dbContext.SaveChangesAsync() в Action, только на уровне эндпоинта

    По сути это похоже на медиатор, но медиатор - это сервис локатор, нам нужно погрузиться в бизнес логику, чтобы понять какие хендлеры он вызывает, тут же мы инжектим Action по имени класса, поэтому мы понимаем какие Action будут вызваны внутри и их можно замокать в тестах


    1. antonb73 Автор
      08.10.2025 10:53

      У меня вопрос, как вы это будете регистрировать в ServiceProvider?

      IAction<Guid, UserDto>

      Уточню, если у разных классов сигнатуры совпадают?


      1. VanKrock
        08.10.2025 10:53

        По классу регистрация

        public static IServiceCollection AddActions(this IServiceCollection services)
            {
                var actionTypes = Assembly
                    .GetExecutingAssembly()
                    .GetTypes()
                    .Where(t => typeof(IAction<,>).IsAssignableFrom(t) && t.IsClass && !t.IsAbstract);
                foreach (var actionType in actionTypes)
                {
                    services.AddScoped(actionType);
                }
                return services;
            }
        

        Нас не интересует получение по IAction<,>, он только как интерфейс маркер для того чтобы получить все классы которые его реализуют, чтобы можно было их зарегистрировать, ну и чтобы название метода строго задать, чтобы всё вразнабой не было

        Работаем с Actions так

        public record GetUserRequest(Guid Id);
        
        public class GetUserEndpoint(GetUserAction getUserAction): IEndpoint<GetUserRequest, UserDto>
        {
            public Task<UserDto> Handle(GetUserRequest request, CancellationToken cancellationToken)
                => getUserAction.Execute(request.Id, cancellationToken);
        }
        


        1. antonb73 Автор
          08.10.2025 10:53

          Да, я именно такого ответа и ожидал, так как это для меня пройденный этап.

          По сути вместо определения уникальности типа через его имя, вы уникальный тип определяете через уникальное сочетание его параметров, для простых типов параметров вы используете обёртки record.

          На мой взгляд добавление сущностей не остановлено их всё равно будет не меньше чем требуется для описания домента, но вводится дополнительная абстракция IAction<TQuery, TResult> которая не несёт никакой полезной нагрузки, она нужна чтобы реализовать уникальность типов через сочетание параметров.

          При увеличении сложности проекта вы сами придёте к выводу, что этот подход недостаточно снижает сложность кода, будете искать более эффективный способ борьбы со сложностью.

          Второе - как я понял вы не используете абстракции для моков, а добавляете, вынужденно, признак virtual ко всем public, internal методам класса именно только с целью возможности корректного написания тестов. Тут явная избыточность, и приглашение унаследоватся от класса и переопределить метод - проблем много, их не решить договорённостями, запретом не переопределять, не наследовать, особенно если команда большая.

          Грабли торчат отовсюду. На них обязательно наступят.



          1. VanKrock
            08.10.2025 10:53

            Это не так, тут почти всё как в вашем примере, IAction - это ваш IBusinesOperation, просто букв меньше, и он параметризирован, чтобы можно было указать входные и выходные параметры, а не просто void и без входных параметров, из контейнера мы получаем sp.GetRequiredService<GetUserAction>() а не sp.GetRequiredService<IAction<Guid, UserDto>>()