Comments 18
Зачем нужна обертка над лямбдой - только ради инжекта?
Функциональный объект может в себе хранить, например, другие объекты. Безусловно все можно реализовать и чисто лямбдами. Насколько удобно - дело вкуса. В конце концов замыкания лямбд это и есть анонимные (скрытые) объекты.
Как пример вычисление скидки, где объект либо является элементарной скидкой, либо коллекцией скидок, которые могут браться по максимуму или суммироваться или приоритет... Причем сами элементы коллекции сами являются такими же скидками - получаем дерево скидок, которое вычитывается из базы данных, например, динамически.
Конечно же иньекция зависимостей делается не радии иньекции, а ради повторного использования данных, состояния одной или нескольких зависимостей.
Если в вашем приложении нет хранимых состояний, вам не нужны объекты, достаточно будет голых функций, и конечно же ФП лучше подойдёт в этом случае.
Возможны компромисные варианты построения кода, когда объекты используются только там, где возникают хранимые состояния, остальной код реализуется делегатами. Преимуществами такого подхода является отсутствие избыточности, но недостатком является отсутствие гибкости. В бизнес-приложениях часто меняются требования, что может повлечь за собой серьёзные переработки в коде с заменой делегатов на объекты. В этом случае избыточная реализация с использованием Function Object выглядит предпочтительнее, так как поддержка изоляции состояний, данных присутствует изначально, так как все функции изначально являются объектами.
Наверное не стоит повторять, что не существует универсальных решений. Function Object также не является серебрянной пулей.
Откуда вы эти антипатерны достали? Хватит по чуланам шариться.
Даже у Шарп, который в основе ООП умеет с функциями как
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) => ...
}
Честно говоря, имел бы возможность вас забанить, то с удовольствием сделал бы это. Не люблю хамов любого пошива.
Ещё не люблю хитрецов подменяющих понятия. В ваших примерах это не C# умеет с функциями так, а это конкретные типы имеют соответствующие методы, а последний пример, это вообще отдельная библиотека которая занимается кодогенерацией.
Прежде чем писать подумайте в чём ценность вашего комментария.
Странный аргумент "это не C# умеет с функциями так".
=> - конструкция языка
return switch Invoice inv when ... => - конструкция языка
[() => return notifier.Send()] - конструкция языка
Про "конкретные типы имеют соответствующие методы" - у вас FunctionObject.Execute
это конечно же не тип с методом?
Переименуйте PayInvoiceOperation
в PayInvoiceService
, Invoke
в PayAsync
, получите обычный "сервис" которые прямо сейчас пишут в любых проектах и не только на дотнете.
Выглядит отвратительно же, как это читать вообще, вместо нормальной всем понятной логики сплошной бойлерплейт
Я прочитал и предыдущую статью, и эту, и комменты частично. Долго медитировал на счет корректности паттерна. С одной стороны, раскидать логику по разным юзкейсам - это идея здравая. С другой - чувствуется, что что-то не то... И в общем я пришел к выводу, что в статье просто описывается принип SRP: https://habr.com/ru/articles/454290/
Но это не функциональный объект, потому что:
функциональный объект в C# уже есть, это - делегат
таскать внешние зависимости из DI - это в общем-то антипаттерн для ФП; функции должны быть чистыми, иначе шибко их не закомбинируешь
Несмотря на путаницу в терминологии, идея в общем-то здравая имхо. Язык не повернется назвать антипаттерном... Если в фасад обернуть, так - вообще тема. Главное - слишком сильно не увлекаться декомпозицией, т.к. с большим количеством мелких объектов тоже проблематично работать
upd
Мой последний проект состоял из более чем 250 микросервисов, и включал в себя такие группы функций как ETL, Search, RAG, и только в части из них были использованы Function Object
Подтверждаю. В микросервисах может оказаться очень кайфово просто раскидывать по юзкейсам код. Там какой-нибудь MediatR + Minimal Api или FastEndpoints - и в путь. Нет смысла особо что-то инженерить, т.к. архитектурные границы уже были проведены на уровне сервисов
Но это не функциональный объект, потому что:
функциональный объект в C# уже есть, это - делегат
Тульский пряник, это не пряник, потому что уже есть мятный пряник.
таскать внешние зависимости из DI - это в общем-то антипаттерн для ФП; функции должны быть чистыми, иначе шибко их не закомбинируешь
ФП ? Кто то говорил о ФП?
ФП ? Кто то говорил о ФП?
Ты сам же :D
Термин Функция-объект (Function Object, или Functor) в контексте объектно-ориентированного программирования появился как естественное развитие концепции функций как объектов из функционального программирования
Такой подход часто используется, например, для событий, коллбеков или обработки команд из UI
Удачи юзнуть "функтор" с зависимостью от EF Core как коллбек на событие, допустим
---
Тульский пряник, это не пряник, потому что уже есть мятный пряник.
Зачем изобретать тульский пряник на базе мятного пряника?
---
Еще раз повторюсь, что идея здравая. Хороший подход, который имеет место быть. Но в объяснении приплетены паттерны совсем из другой оперы с другими свойствами и предназначениями
https://habr.com/ru/articles/954516/#comment_28943544
надеюсь, что этот комментарий снимет недопонимание
Моя придирка скорее в том, что использование терминов, заимствованных из ФП, тут ни к месту, т.к. из ФП ничего не заимствуется в итоге - в ходе всех манипуляций мы получаем обычные объекты...
А если речь идет о том, чтобы полностью конструировать бизнес-логику из таких "объектов-функций", то в таком случае речь идет больше об уходе от ООП, чем о его паттернах. Что-то отдаленно напоминающее процедурное программирование получается ( просто пытаюсь сформулировать, как правильно называется такой подход; если честно, не знаю :D )
Идея хорошая, мы тоже к такой пришли, но вот реализация как тут совсем не гибкая, нет особого смысла разделять 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 будут вызваны внутри и их можно замокать в тестах
У меня вопрос, как вы это будете регистрировать в ServiceProvider?IAction<
Guid
, UserDto>
Уточню, если у разных классов сигнатуры совпадают?
По классу регистрация
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);
}
Да, я именно такого ответа и ожидал, так как это для меня пройденный этап.
По сути вместо определения уникальности типа через его имя, вы уникальный тип определяете через уникальное сочетание его параметров, для простых типов параметров вы используете обёртки record.
На мой взгляд добавление сущностей не остановлено их всё равно будет не меньше чем требуется для описания домента, но вводится дополнительная абстракция IAction<TQuery, TResult> которая не несёт никакой полезной нагрузки, она нужна чтобы реализовать уникальность типов через сочетание параметров.
При увеличении сложности проекта вы сами придёте к выводу, что этот подход недостаточно снижает сложность кода, будете искать более эффективный способ борьбы со сложностью.
Второе - как я понял вы не используете абстракции для моков, а добавляете, вынужденно, признак virtual ко всем public, internal методам класса именно только с целью возможности корректного написания тестов. Тут явная избыточность, и приглашение унаследоватся от класса и переопределить метод - проблем много, их не решить договорённостями, запретом не переопределять, не наследовать, особенно если команда большая.
Грабли торчат отовсюду. На них обязательно наступят.
Это не так, тут почти всё как в вашем примере, IAction - это ваш IBusinesOperation, просто букв меньше, и он параметризирован, чтобы можно было указать входные и выходные параметры, а не просто void и без входных параметров, из контейнера мы получаем sp.GetRequiredService<GetUserAction>()
а не sp.GetRequiredService<IAction<Guid, UserDto>>()
Function Object — как основа бизнес логики приложения