Инверсия зависимостей - один из принципов SOLID, который лежит в основе построения гексагональной архитектуры приложения. Существует множество статей, которые раскрывают суть принципа и объясняют как его применять. И, возможно, читатель уже знаком с ними. Но в рамках данной статьи будет продемонстрирован подробный разбор "тактических" приемов для успешного использования инверсии зависимостей и, возможно, в этом смысле даже искушенный читатель сможет найти для себя что-то новое. Примеры представлены на языке программирования Java с соответствующим окружением, но при этом для чтения достаточно понимания похожих языков программирования.

0. Проблема взаимодействия изолируемого модуля с инфраструктурным

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

В начале рассмотрим классы-данных, соответствующие процессу:

CreateOrderRequest - класс-данных запроса, который отправляет пользователь для создания заказа на товар

package aa0ndrey.dependency_inversion_guide.step_0.core.order;

public class CreateOrderRequest {
    private UUID userId;
    private UUID productId;
}

User - класс-данных пользователя

package aa0ndrey.dependency_inversion_guide.step_0.core.user;

public class User {
    private UUID id;
    private String name;
    private int balance;
}

Product - класс-данных товара

package aa0ndrey.dependency_inversion_guide.step_0.core.product;

public class Product {
    private UUID id;
    private String title;
    private int price;
}

Order - класс-данных заказа

package aa0ndrey.dependency_inversion_guide.step_0.core.order;

public class Order {
    private UUID id;
    private UUID userId;
    private UUID productId;
}

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

package aa0ndrey.dependency_inversion_guide.step_0.postgres.user;

public class UserRepositoryImpl {
    public User find(UUID id) {
        //реализация select * from user where user.id = ?
    }
}
package aa0ndrey.dependency_inversion_guide.step_0.postgres.product;

public class ProductRepositoryImpl {
    public Product find(UUID id) {
        //реализация select * from product where product.id = ?
    }
}
package aa0ndrey.dependency_inversion_guide.step_0.postgres.order;

public class OrderRepositoryImpl {
    public void create(Order order) {
        //реализация insert into order (id, user_id, product_id) values (?, ?, ?)
    }
}

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

package aa0ndrey.dependency_inversion_guide.step_0.core.order;

import aa0ndrey.dependency_inversion_guide.step_0.postgres.order.OrderRepositoryImpl;
import aa0ndrey.dependency_inversion_guide.step_0.postgres.product.ProductRepositoryImpl;
import aa0ndrey.dependency_inversion_guide.step_0.postgres.user.UserRepositoryImpl;

public class OrderService {
    private final UserRepositoryImpl userRepository;
    private final ProductRepositoryImpl productRepository;
    private final OrderRepositoryImpl orderRepository;

    public void create(CreateOrderRequest request) {
        var user = userRepository.find(request.getUserId());
        var product = productRepository.find(request.getProductId());

        if (user.getBalance() < product.getPrice()) {
            throw new RuntimeException("Недостаточно средств");
        }

        var order = new Order(UUID.randomUUID(), user.getId(), product.getId());
        orderRepository.create(order);
    }
}

Теперь предположим, что необходимо разложить представленные классы по двум модулям: core и postgres. Ожидается, что модуль core будет содержать в себе основную логику приложения, которую необходимо изолировать от инфраструктурных зависимостей. В свою очередь в postgres модуле ожидается, что будет размещен весь код, связанный с работой с базой данных postgres.

Важным нюансом является то, что postgres модуль должен зависеть от core модуля, а core модуль не должен зависеть от postgres модуля. Postgres и core модули будут собраны с помощью maven как отдельные maven модули. Maven - это инструмент, предназначенный для сборки проекта и управления зависимостями в проекте. Ниже будут представлены конфигурационные pom.xml файлы для core и postgres модуля.

Файл pom.xml, содержащий конфигурацию core модуля

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>aa0ndrey</groupId>
    <artifactId>dependency-inversion-guide-step-0-core</artifactId>
    <version>1.0-SNAPSHOT</version>
</project>

Файл pom.xml, содержащий конфигурацию postgres модуля

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>aa0ndrey</groupId>
    <artifactId>dependency-inversion-guide-step-0-postgres</artifactId>
    <version>1.0-SNAPSHOT</version>

    <dependencies>
        <dependency>
            <groupId>aa0ndrey</groupId>
            <artifactId>dependency-inversion-guide-step-0-core</artifactId> <!--(1)-->
            <version>1.0-SNAPSHOT</version>
        </dependency>
    </dependencies>
</project>

Если вы не знакомы с maven, то важным, на что необходимо обратить внимание, является блок <dependencies></dependecies>. В postgres модуле есть ссылка на core модуль. А в core модуле нет блока <dependencies></dependecies> совсем, что в частности означает что нет зависимости на модуль postgres. Важно отметить, что maven не позволит скомпилировать модуль, ссылающийся на код модуля, которого нет в зависимостях. Также в maven запрещены циклические зависимости, то есть, если postgres модуль зависит от core модуля, то core модуль не может зависеть от postgres модуля.

В файле конфигурации pom.xml для postgres модуля есть комментарий <!--(1)--> здесь и далее комментарии будут использованы, в том числе для указания ссылок на код. При этом ссылка на код оформляется в блоке кода с помощью круглых скобок, внутри которых указана цифра. На эту цифру в основном тексте будет ссылка. Например, так: в файле pom.xml для postgres модуля в (1) указана зависимость на core модуль.

Теперь разложим рассмотренные ранее классы по модулям core и postgres.

Файловая структура core модуля

├── order
│   ├── CreateOrderRequest.java
│   ├── Order.java
│   └── OrderService.java
├── product
│   └── Product.java
└── user
    └── User.java

Файловая структура postgres модуля

├── order
│   └── OrderRepositoryImpl.java
├── product
│   └── ProductRepositoryImpl.java
└── user
    └── UserRepositoryImpl.java

На самом деле как разложены классы внутри модулей, можно было понять ранее. В примерах кода всюду указаны пакеты package. При именовании пакетов после aa0ndrey.dependency_inversion_guide указывается номер рассматриваемого примера, в данном случае step-0, и затем указывается имя модуля, в котором расположен класс. Например, класс, с указанным package aa0ndrey.dependency_inversion_guide.step_0.core.order, находится в модуле core.

Если попробовать собрать эти два модуля, то при сборке модуля core возникнет ошибка, связанная с тем, что в классе OrderService используются OrderRepositoryImpl, ProductRepositoryImpl и UserRepositoryImpl, которых нет в модуле core и в подключаемых зависимостях, потому что классы этих репозиториев находятся в модуле postgres, на который невозможно и, что самое главное не нужно создавать зависимость в модуле core. И тут становится понятна проблема, которая возникает при желании изолировать модуль от другого инфраструктурного модуля, из которого по коду необходимо получать и в который необходимо отправлять данные. Так как же это сделать? Решение будет рассмотрено в следующем разделе.

1. Инверсия зависимостей с помощью интерфейсов

Чтобы решить проблему, обозначенную в предыдущем разделе, достаточно в core модуле создать интерфейсы для репозиториев, а сами репозитории из postgres модуля заставить реализовывать указанные интерфейсы.
Добавим интерфейсы репозиториев в core модуль

package aa0ndrey.dependency_inversion_guide.step_1.core.user;

public interface UserRepository {
    User find(UUID id);
}
package aa0ndrey.dependency_inversion_guide.step_1.core.product;

public interface ProductRepository {
    Product find(UUID id);
}
package aa0ndrey.dependency_inversion_guide.step_1.core.order;

public interface OrderRepository {
    void create(Order order);
}

Затем укажем реализацию этих репозиториев в postgres модуле

package aa0ndrey.dependency_inversion_guide.step_1.postgres.user;

public class UserRepositoryImpl implements UserRepository {
    @Override
    public User find(UUID id) {
        //реализация select * from user where user.id = ?
    }
}
package aa0ndrey.dependency_inversion_guide.step_1.postgres.product;

public class ProductRepositoryImpl implements ProductRepository {
    @Override
    public Product find(UUID id) {
        //реализация select * from product where product.id = ?
    }
}
package aa0ndrey.dependency_inversion_guide.step_1.postgres.order;

public class OrderRepositoryImpl implements OrderRepository {
    @Override
    public void create(Order order) {
        //реализация insert into order (id, user_id, product_id) values (?, ?, ?)
    }
}

И в заключении перепишем логику в OrderService так, чтобы он начал использовать вместо UserRepositoryImpl, ProductRepositoryImpl и OrderRepositoryImpl их интерфейсы, которые расположены в модуле core.

package aa0ndrey.dependency_inversion_guide.step_1.core.order;

public class OrderService {
    private final UserRepository userRepository;
    private final ProductRepository productRepository;
    private final OrderRepository orderRepository;

    public void create(CreateOrderRequest request) {
        var user = userRepository.find(request.getUserId());
        var product = productRepository.find(request.getProductId());

        if (user.getBalance() < product.getPrice()) {
            throw new RuntimeException("Недостаточно средств");
        }

        var order = new Order(UUID.randomUUID(), user.getId(), product.getId());
        orderRepository.create(order);
    }
}

Файловая структура core модуля

├── order
│   ├── CreateOrderRequest.java
│   ├── Order.java
│   ├── OrderRepository.java (1)
│   └── OrderService.java
├── product
│   ├── Product.java
│   └── ProductRepository.java (2)
└── user
    ├── User.java
    └── UserRepository.java (3)

Файловая структура postgres модуля

├── order
│   └── OrderRepositoryImpl.java (4)
├── product
│   └── ProductRepositoryImpl.java (5)
└── user
    └── UserRepositoryImpl.java (6)

Этими изменениями удалось достигнуть того, что core модуль перестал использовать какие-либо классы из postgres модуля. И теперь оба модуля могут быть скомпилированы. Вместо использования прямых зависимостей на классы из инфраструктурного модуля (postgres) (4), (5), (6) можно создавать интерфейсы (1), (2), (3) на эти классы в изолируемом модуле (core), которые должны быть реализованы в инфраструктурном модуле (postgres). При этом нет никаких проблем в том, что классы из инфраструктурного модуля (postgres) зависят от классов из изолируемого модуля (core).

  ┌────────────────┐
  │      Core      │
  │┌──────────────┐│
  ││UserRepository│◄───────┐
  │└──────────────┘│       │
  └────────────────┘       │
┌────────────────────┐     │
│      Postgres      │     │
│┌──────────────────┐│     │
││UserRepositoryImpl│┼─────┘
│└──────────────────┘│
└────────────────────┘

Важно понимать и идейную составляющую данного приема. Она заключается в том, что изолируемый модуль (core) предъявляет требования к реализации с помощью интерфейса. То есть "главным" в этом отношении является интерфейс, под который подстраивается реализация.

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

2. Проблема использования интерфейсов, раскрывающих инфраструктуру

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

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

Тогда можно в модуле core создать интерфейс, который позволяет управлять транзакциями.

package aa0ndrey.dependency_inversion_guide.step_2.core.transaction_manager;

public interface TransactionManager {
    void begin();

    void commit();
}

В свою очередь реализацию этого интерфейса стоит поместить в модуль postgres

package aa0ndrey.dependency_inversion_guide.step_2.postgres.transaction_manager;

public class TransactionManagerImpl implements TransactionManager {
    public void begin() {
        //реализация начала транзакции
    }

    public void commit() {
        //реализация фиксации транзакции
    }
}

И теперь появляется возможность добавить использование интерфейса TransactionManager в класс с основной логикой OrderService.

package aa0ndrey.dependency_inversion_guide.step_2.core.order;

public class OrderService {
    private final UserRepository userRepository;
    private final ProductRepository productRepository;
    private final OrderRepository orderRepository;
    private final TransactionManager transactionManager;

    public void create(CreateOrderRequest request) {
        transactionManager.begin(); //(1)

        var user = userRepository.find(request.getUserId());
        var product = productRepository.find(request.getProductId());

        if (user.getBalance() < product.getPrice()) {
            throw new RuntimeException("Недостаточно средств");
        }

        var order = new Order(UUID.randomUUID(), user.getId(), product.getId());
        orderRepository.create(order);

        transactionManager.commit(); //(2)
    }
}

В классе OrderService в (1) транзакция открывается, а в (2) происходит ее фиксация. Вообще говоря, код из (2) нужно было бы написать в конструкции try-catch-finally, также при этом используя rollback в случае исключения, но напомню, что примеры намерено упрощены. А еще лучше было бы использовать аннотацию @Transactional из какого-либо фреймворка, но это не является предметом для обсуждения в данной статье. Использование упрощенной версии интерфейса TransactionManager мотивировано тем, что он хорошо известен, по крайней мере Java-разработчикам, и с его участием удобно рассматривать большинство примеров.

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

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

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

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

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

3. Инверсия зависимостей с помощью шаблона проектирования наблюдатель

В данном разделе будет предложено решение проблемы из предыдущего раздела. Вместо того чтобы использовать интерфейсы, раскрывающие инфраструктурные особенности, можно использовать шаблон проектирования наблюдатель (observer). Идея заключается в том, чтобы для класса OrderService создать общий интерфейс для наблюдателей, которые смогут обрабатывать общие события. При этом сами события будут отправляться в те моменты, когда должен был быть вызван нежелательный раскрывающий детали интерфейс (в данном случае TransactionManager).

Создадим для начала классы событий: CreateOrderEvents.Start и CreateOrderEvents.End

package aa0ndrey.dependency_inversion_guide.step_3.core.order;

public class CreateOrderEvents {
    public static class Start {
        private CreateOrderRequest request; //(1)
    }

    public static class End {
        private CreateOrderRequest request; //(2)
        private User user; //(3)
        private Product product; //(4)
        private Order order; //(5)
    }
}

При выборе названия события можно отталкиваться от названия места по ходу выполнения основной логики в OrderService. Например, событие CreateOrderEvents.Start будет отправляться в самом начале метода при создании заказа. Поэтому оно называется Start. В свою очередь событие CreateOrderEvents.End отправляется в конце метода и поэтому оно называется End. Если бы понадобилось отправлять событие перед проверкой баланса пользователя, то можно было бы назвать событие BeforeCheckUserBalance.

Тут важным является не дать название событием так, чтобы по названию можно было бы понять, какая обработка ожидает эти события. Иначе тем самым будут раскрыты детали реализации. Например, такие названия событий не подойдут: OnTransactionBegin и OnTransactionCommit.

Также стоит отметить, что события необходимо наполнить не конкретными данными, которые будут необходимы конкретному наблюдателю. А общими данными, которые доступны в момент отправки события. Поэтому для события CreateOrderEvents.Start указывается только поле с запросом (1), так как только оно доступно в начале метода. В свою очередь в конце выполнения метода доступны: исходный запрос (2), полученный пользователь (3), полученный продукт (4), а также созданный заказ (5).

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

Далее можно создать общий интерфейс для наблюдателя:

package aa0ndrey.dependency_inversion_guide.step_3.core.order;

public interface CreateOrderObserver {
    void onStart(CreateOrderEvents.Start event);

    void onEnd(CreateOrderEvents.End event);
}

И затем можно изменить класс OrderService

package aa0ndrey.dependency_inversion_guide.step_3.core.order;

public class OrderService {
    private final UserRepository userRepository;
    private final ProductRepository productRepository;
    private final OrderRepository orderRepository;
    private final List<CreateOrderObserver> observers; //(6)

    public void create(CreateOrderRequest request) {
        var startEvent = new CreateOrderEvents.Start(request); //(7)
        observers.forEach(observer -> observer.onStart(startEvent)); //(8)

        var user = userRepository.find(request.getUserId());
        var product = productRepository.find(request.getProductId());

        if (user.getBalance() < product.getPrice()) {
            throw new RuntimeException("Недостаточно средств");
        }

        var order = new Order(UUID.randomUUID(), user.getId(), product.getId());
        orderRepository.create(order);

        var endEvent = new CreateOrderEvents.End( //(9)
                request,
                user,
                product,
                order
        );
        observers.forEach(observer -> observer.onEnd(endEvent)); //(10)
    }
}

На что стоит обратить внимание? Было удалено всякое использование интерфейса TransactionManager более того, он был удален из модуля core. Вместо него теперь используется CreateOrderObserver в (6). Для этого в начале метода создаётся событие CreateOrderEvents.Start в (7) и затем оно отправляется наблюдателям в (8). Аналогично в конце метода создаётся событие CreateOrderEvents.End в (9) и отправляется в (10).

И затем можно добавить реализацию наблюдателя в postgres модуль

package aa0ndrey.dependency_inversion_guide.step_3.postgres.order;

public class CreateOrderObserverImpl implements CreateOrderObserver {
    private final TransactionManagerImpl transactionManagerImpl;

    @Override
    public void onStart(CreateOrderEvents.Start event) {
        transactionManagerImpl.begin();
    }

    @Override
    public void onEnd(CreateOrderEvents.End event) {
        transactionManagerImpl.commit();
    }
}

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

Теперь приведём текущую файловую структуру модуля core

├── order
│   ├── CreateOrderEvents.java
│   ├── CreateOrderRequest.java
│   ├── CreateOrderObserver.java
│   ├── Order.java
│   ├── OrderRepository.java
│   └── OrderService.java
├── product
│   ├── Product.java
│   └── ProductRepository.java
└── user
    ├── User.java
    └── UserRepository.java

И модуля postgres

├── order
│   ├── CreateOrderObserverImpl.java
│   └── OrderRepositoryImpl.java
├── product
│   └── ProductRepositoryImpl.java
├── transaction_manager
│   └── TransactionManagerImpl.java
└── user
    └── UserRepositoryImpl.java

Чего в итоге удалось добиться? Удалось полностью избавиться от интерфейса TranasactionManager, который частично раскрывал детали реализации инфраструктурного модуля (postgres). И удалось это сделать за счет добавления отправки событий. Стоит отметить, что таких отправок событий можно добавить сколько угодно по ходу метода. И тем самым у разработчика появляется общий механизм для расширения основной логики находящейся в (core) модуле. И с помощью этого можно внедрять произвольную логику, находящуюся в инфраструктурном модуле (postgres). Важно отметить, что такие отправки событий можно использовать не обязательно только в начале и конце метода, но и где-то посередине. Эту особенность стоит запомнить, так как в будущих разделах, когда будет рассмотрен промежуточный модуль (application), будут предложены другие решения, которые не позволяют внедрить какое-либо расширение посередине метода.

Интересно также отметить то, что на самом деле данное решение является небольшой модификацией стандартной инверсии зависимостей. В стандартном решении используется конкретный интерфейс, например, TransactionManager и вызываются его конкретные методы begin и commit, и передаются конкретные данные, но для методов begin и commit их просто нет. В решении через использование шаблона наблюдатель, интерфейс обобщается в смысле названия и в данном случае это CreateOrderObserver. И также обобщаются его методы и передаваемые данные. Методы становятся onStart и onEnd, а передаваемые данные, то есть события, как это было показано ранее, содержат общие для всех наблюдателей поля.

4. Использование контекста вместо событий

Предположим теперь, что реализация TransactionManagerImpl изменилась.

package aa0ndrey.dependency_inversion_guide.step_4.postgres.transaction_manager;

public class TransactionManagerImpl {
    public long begin() { //(1)
        //реализация начала транзакции
    }

    public void commit(long transactionId) { //(2)
        //реализация фиксации транзакции
    }
}

Теперь в (1) метод begin возвращает id текущей транзакции. В свою очередь, в (2) метод commit принимает id транзакции, которую необходимо зафиксировать. То есть теперь появилась потребность передать данные от одного инфраструктурного вызова к другому. Но как это сделать, если теперь эти вызовы осуществляются изолировано в рамках обработки событий по шаблону проектирования наблюдатель? Если проблема не стала ясна, то для понимания рекомендуется вернуться к классам OrderService и CreateOrderObserverImpl из предыдущего раздела 3 и попытаться представить решение, которое позволит передать id транзакции от метода begin в метод commit.

Стоит отметить, что подобная проблема не возникла, если бы не было принято решение отказаться от интерфейса TransactionManager. Правда, в этом случае в методе с основной логикой в изолируемом модуле (core) начала бы появляться дополнительная логика по работе с транзакциями. То есть основной код начал бы сильнее обрастать инфраструктурными деталями, так как в него была бы добавлена еще и логика по запоминанию id транзакции, чтобы затем использовать этот id для фиксации транзакции.

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

package aa0ndrey.dependency_inversion_guide.step_4.core.order;

public class CreateOrderContext {
    private CreateOrderRequest request; //(3)
    private User user; //(4)
    private Product product; //(5)
    private Order createdOrder; //(6)

    private Map<String, Object> data; //(7)
}

CreateOrderContext содержит в (3) поле исходного запроса, в (4) найденного пользователя, в (5) найденный продукт, в (6) созданный заказ. Состав полей очень похож на событие CreateOrderEvents.End за исключением поля (7). Это поле как раз сейчас и понадобится.

Идея в том, что все поля, которые известны основному процессу, находящемуся в изолируемом модуле (core), имеют строгий формат в контексте с конкретными именами, потому что они известны этому процессу. В свою очередь поле data в (7) представлено ассоциативным массивом, где в качестве ключа используется строка, а в качестве значения используется Object, то есть самый общий тип данных в Java, наследниками которого являются все остальные классы. Это позволяет в поле data записывать совершенно произвольные данные в динамическом формате не фиксируя структуру. А это значит, что записывая туда какие-либо инфраструктурные данные, не создается зависимости в основном коде от инфраструктуры, до тех пор пока в нем, в основном коде, не используется поле data, что делать не рекомендуется.

Теперь изменим интерфейс CreateOrderObserver таким образом, чтобы вместо событий методы принимали контекст.

package aa0ndrey.dependency_inversion_guide.step_4.core.order;

public interface CreateOrderObserver {
    void onStart(CreateOrderContext context);

    void onEnd(CreateOrderContext context);
}

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

Теперь изменим логику в OrderService так, чтобы использовался контекст

package aa0ndrey.dependency_inversion_guide.step_4.core.order;

public class OrderService {
    private final UserRepository userRepository;
    private final ProductRepository productRepository;
    private final OrderRepository orderRepository;
    private final List<CreateOrderObserver> observers;

    public void create(CreateOrderContext context) {
        observers.forEach(observer -> observer.onStart(context));

        var request = context.getRequest();
        var user = userRepository.find(request.getUserId());
        context.setUser(user); //(8)
        var product = productRepository.find(request.getProductId());
        context.setProduct(product); //(9)

        if (user.getBalance() < product.getPrice()) {
            throw new RuntimeException("Недостаточно средств");
        }

        var order = new Order(UUID.randomUUID(), user.getId(), product.getId());
        orderRepository.create(order);
        context.setCreatedOrder(order); //(10)

        observers.forEach(observer -> observer.onEnd(context));
    }
}

Необходимо отметить, что каждый раз когда появляются новые важные объекты, в методе create они обязательно добавляются в контекст в (8), (9), (10). Зачем это делается? А это на самом деле делается аналогично использованию событий. При разработке основной логики нет знания о том, как наблюдатель будет реагировать на то или иное событие. И потенциально ему для работы могут понадобиться данные, которые появляются в основной логике.

И теперь можно перейти к самому важному, к изменению реализации интерфейса CreateOrderObserver, то есть к классу CreateOrderObserverImpl.

package aa0ndrey.dependency_inversion_guide.step_4.postgres.order;

public class CreateOrderObserverImpl implements CreateOrderObserver {
    private final TransactionManagerImpl transactionManagerImpl;

    @Override
    public void onStart(CreateOrderContext context) {
        var transactionId = transactionManagerImpl.begin();
        context.getData().put("transaction-id", transactionId); //(11) 
    }

    @Override
    public void onEnd(CreateOrderContext context) {
        var transactionId = (Long) context.getData().get("transaction-id"); //(12)
        transactionManagerImpl.commit(transactionId);
    }
}

Тут все устроено следующем образом. При открытии транзакции в методе
onStart в (11) id транзакции сохраняется в контексте в поле data по ключу transaction-id. Затем уже в методе onEnd, когда необходимо зафиксировать транзакцию, в (12) из контекста по этому же самому ключу извлекается id транзакции, который затем используется для её фиксации.

Чего в итоге удалось добиться? За счет использования контекста между двумя изолированными методами для обработки событий внутри инфраструктурного модуля удалось передать данные. При этом за счёт обобщенного поля data внутри контекста детали реализации инфраструктурного модуля не проникли в изолируемый модуль (core). Тут является очень важным, что в контексте не появилось конкретного поля, такого как long transactionId, которое раскрывало бы детали реализации.

5. Альтернативы полю data из контекста

В данном разделе будут обсуждаться альтернативы полю data из контекста для того, чтобы передавать данные из одного инфраструктурного метода в другой. В Java есть механизм, позволяющий привязать данные к потоку. Разрабатываемое приложение может быть построено так, что для каждого обрабатываемого запроса выделяется отдельный поток. И этот поток может эксклюзивно использоваться логикой обработки, до тех пор пока она не завершится. В этом случае можно использовать ThreadLocal в качестве альтернативы полю data.

Рассмотрим, как можно изменить CreateOrderObserverImpl для того, чтобы использовать ThreadLocal вместо data

package aa0ndrey.dependency_inversion_guide.step_5.postgres.order;

public class CreateOrderObserverImpl implements CreateOrderObserver {
    private final TransactionManagerImpl transactionManagerImpl;
    private final ThreadLocal<Long> transactionId = new ThreadLocal<>(); //(1)

    @Override
    public void onStart(CreateOrderContext context) {
        transactionId.set(transactionManagerImpl.begin());
    }

    @Override
    public void onEnd(CreateOrderContext context) {
        transactionManagerImpl.commit(transactionId.get());
    }
}

Для этого достаточно завести поле transactionId, как это сделано в (1). И затем можно рассмотреть, что происходит в самих методах. При вызове метода onStart происходит запись id транзакции в поле transactionId, а в методе onEnd значение извлекается из поля transactionId для того, чтобы зафиксировать транзакцию. Сигнатуры методов onStart и onEnd остались без изменений и все также принимают контекст, но в данном случае контекст совсем не используется.

Может возникнуть вопрос, а почему ранее нельзя было создать переменную long transactionId и использовать ее аналогично? Предполагалось, что каждый класс сервиса, репозитория и наблюдателя имеет по одному экземпляру в работающем приложении, то есть они реализуют шаблон одиночка (singleton). И в этом случае один и тот же экземпляр класса может параллельно использоваться в нескольких потоках, и потоки могут друг другу мешать, конкурируя за единственное поле. Поэтому прямое использование переменной типа long не подойдёт. В свою очередь, тип ThreadLocal будет гарантировать, что каждый поток будет работать со своим значением.

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

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

6. Передача данных от инфраструктурного модуля в изолируемый модуль

Что если теперь потребуется отправлять данные не только из изолируемого модуля (core) в инфраструктурный модуль (postgres), но и обратно из инфраструктурного в изолируемый. Такая необходимость тоже может возникнуть. Но в рамках данного раздела будут предъявлены самые ультимативные требования. Пусть в основной логике в классе OrderService необходимо отказаться от использования всех интерфейсов репозиториев. Для этого на самом деле уже все есть. В данном случае достаточно использовать уже созданный ранее контекст со всеми его полями.

Для начала рассмотрим изменения, внесенные в класс OrderService

package aa0ndrey.dependency_inversion_guide.step_6.core.order;

public class OrderService {
    private final List<CreateOrderObserver> observers;

    public void create(CreateOrderContext context) {
        observers.forEach(observer -> observer.onStart(context));

        var user = context.getUser(); //(1)
        var product = context.getProduct(); //(2)

        if (user.getBalance() < product.getPrice()) {
            throw new RuntimeException("Недостаточно средств");
        }

        var order = new Order(UUID.randomUUID(), user.getId(), product.getId());
        context.setCreatedOrder(order); //(3)

        observers.forEach(observer -> observer.onEnd(context));
    }
}

Теперь вместо использования репозиториев напрямую для получения данных, необходимых для выполнения основной логики, используется контекст. То есть теперь в (1) и (2) информация о пользователе и продукте извлекается из контекста. В свою очередь вместо вызова метода репозитория для сохранения созданного заказа используется контекст, в который добавляется только что созданный заказ в (3). Стоит отметить, что метод create стал меньше и проще.

Но чтобы это работало, необходимо внести изменения в класс CreateOrderObserverImpl

package aa0ndrey.dependency_inversion_guide.step_6.postgres.order;

public class CreateOrderObserverImpl implements CreateOrderObserver {
    private final TransactionManagerImpl transactionManagerImpl;
    private final ThreadLocal<Long> transactionId = new ThreadLocal<>();
    private final UserRepositoryImpl userRepository;
    private final ProductRepositoryImpl productRepository;
    private final OrderRepositoryImpl orderRepository;

    @Override
    public void onStart(CreateOrderContext context) {
        transactionId.set(transactionManagerImpl.begin());
        var request = context.getRequest();
        context.setUser(userRepository.find(request.getUserId())); //(4)
        context.setProduct(productRepository.find(request.getProductId())); //(5)
    }

    @Override
    public void onEnd(CreateOrderContext context) {
        transactionManagerImpl.commit(transactionId.get());
        orderRepository.create(context.getCreatedOrder()); //(6)
    }
}

И здесь как раз в (4), (5) и (6) добавляются все вызовы репозиториев, которые были вынесены из OrderService.

В итоге с помощью внесенных изменений удалось получить возможность передачи данных от инфраструктурного модуля (postgres) в изолируемый модуль (core). Тут стоит обратить внимание, что для этого использовались именно конкретные поля контекста, такие как user и product, а не обобщенное поле data. Это связано стем, что, во-первых, основной логике известны сущности user и product, а, во-вторых, в основной логике не должно использоваться поле data.

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

  • из изолируемого модуля (core) в инфраструктурный модуль (postgres)

  • из инфраструктурного модуля (postgres) в изолируемый модуль (core)

  • между методами обработки событий инфраструктурного модуля.

При этом для отправки из изолируемого модуля (core) в инфраструктурный модуль (postgres) и обратно используются конкретные поля, а для организации взаимодействия между методами обработки событий инфраструктурного модуля используется обобщенное поле data, либо механизмы, обеспечивающие привязку данных к процессу выполнения, такие как ThreadLocal. И не стоит также забывать о том, что точек для отправки событий с использованием контекста можно добавить сколько угодно в метод с основной логикой, даже посередине, а не только в начале и конце. При этом не придется даже создавать отдельные события, так как всю необходимую информацию о процессе содержит сам контекст.

После рассмотрения предыдущих разделов какие можно сделать выводы, уточнения и предостережения?

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

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

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

Итоговый список приемов:

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

  • шаблон наблюдатель - прием инверсии зависимостей, который скрывает прямое использование классов и их методов. Возможны следующие варианты использования шаблона:

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

    • с контекстом без поля data - передача данных от изолируемого модуля в инфраструктурный модуль и обратно

    • с привязкой данных к процессу выполнения - передача данных между изолируемыми инфраструктурными методами обработки событий

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

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

В заключении для лучшего погружения в материал читателю предлагается решить задачу.

Задача

Пусть для каждого запроса при взаимодействии с базой данных необходимо также передавать id транзакции, который появляется при вызове метода begin из TransactionManagerImpl. То есть необходимо внести некоторые изменения, как минимум, в классы UserRepositoryImpl, ProductRepositoryImpl, OrderRepositoryImpl. Какие приемы и как их применить для того, чтобы удовлетворить поставленному требованию, при этом не добавив лишних инфраструктурных зависимостей в основную логику в изолируемый модуль (core)? Приведите как можно больше возможных вариантов решения поставленной задачи.

Возможные решения

Заметим, что данные необходимо передавать между изолированными инфраструктурными методами.

  1. Использовать механизм привязки данных к выполняющемуся процессу, например, ThreadLocal. При этом сам id транзакции будет некорректно хранить внутри CreateOrderObserverImpl, так как репозитории потенциально могут быть переиспользованы в нескольких местах и поэтому они не могут быть привязаны к конкретному наблюдателю. Для этого достаточно завести отдельный класс, который будет хранить информацию о транзакции. Например, TransactionInfo с полем ThreadLocal<Long> transactionId. При этом в реализации TransactionManagerImpl можно затребовать, чтобы id транзакции сохранялся в TransactionInfo при вызове метода begin, а каждый репозиторий при выполнении запроса будет обращаться к TransactionInfo за получением id транзакции.

  2. Использовать контекст с полем data. Для этого создать интерфейс DynamicContext, который будет содержать метод getData(). Также необходимо указать, что CreateOrderContext реализует данный интерфейс. Идея в том, что все методы репозиториев должны будут принимать DynamicContext в качестве дополнительного параметра, из которого они смогут получить доступ к полю data, в котором будет находиться id транзакции. Интерфейс DynamicContext нужен для того, чтобы была возможность переиспользовать репозитории в других местах, поэтому нельзя напрямую использовать тип CreateOrderContext в сигнатуре методов.

  3. Отказаться от использования репозиториев в основной логике в изолируемом модуле (core) и перенести их в наблюдателя CreateOrderObserverImpl, как это было продемонстрировано в последнем разделе. Тогда нет никаких препятствий к тому, чтобы в сигнатуры методов каждого репозитория добавить параметр long transactionId, так как теперь эти методы никогда не будут использоваться в изолируемом модуле (core). Тогда в самом наблюдателе CreateOrderObserverImpl при вызове методов репозиториев появляется возможность передавать напрямую id транзакции.

Ссылка на github репозиторий со всеми примерами.

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


  1. lair
    10.10.2021 10:25
    +1

    Идея заключается в том, чтобы для класса OrderService создать общий интерфейс для наблюдателей, которые смогут обрабатывать общие события. При этом сами события будут отправляться в те моменты, когда должен был быть вызван нежелательный раскрывающий детали интерфейс (в данном случае TransactionManager).

    Вот только семантика у событий "начало"-"конец" и у транзакций — разная, поэтому вы не можете заменить второе на первое без потери смысла. Фактически, ваш OrderService все равно знает, что там внутри транзакция, просто она называется иначе; а CreateOrderObserverImpl знает, где конкретно вызываются эти события, чтобы сделать транзакцию — и тем самым эти модули стали тесно связаны, только через очень неявный интерфейс.


    Транзакция сама по себе достаточно понятная абстракция, не надо ее заменять на события. Если вам очень не нравятся транзакции — есть Unit of Work.


    1. aa0ndrey Автор
      10.10.2021 17:26

      Вот только семантика у событий "начало"-"конец" и у транзакций — разная, поэтому вы не можете заменить второе на первое без потери смысла. 

      Уточню некоторый момент, чтобы быть уверенным, что мы одинаково пониманием контекст. В разделе 3 реализация TransactionManagerImpl осталась без изменений с семантикой методов begin и commit, а интерфейс TransactionManager был удален и заменён на CreateOrderObserver, у которого в реализации в обработке методов onStart и onEnd в классе CreateOrderObserverImpl вызываются методы begin и commit от класса TransactionManagerImpl.

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

      Фактически, ваш OrderService все равно знает, что там внутри транзакция, просто она называется иначе; а CreateOrderObserverImpl знает, где конкретно вызываются эти события, чтобы сделать транзакцию — и тем самым эти модули стали тесно связаны, только через очень неявный интерфейс.

      Прошу вас уточнить, что вы подразумеваете под "знает"? Если смотреть только на код модуля core и класс OrderService в частности отсутствует какое-либо знание о том, что будет открыта и зафиксирована транзакция. Также отсутствует знание о том, как это будет сделано, а именно какие нужно передавать значения в вызоваемые методы. Единственное знание, которое есть в OrderService, это то, что будет отправлено событие всем наблюдателям по ходу выполнения процесса.

      Обратите внимание, когда есть прямой вызов метода, то мы отправляем команду в класс/модуль/сервис, который вызываем. Мы знаем кого вызываем и знаем, что необходимо сделать. Когда вместо прямого вызова происходит отправка события, то сторона, которая отправляет событие не знает, как и кем оно будет обработано.

      Транзакция сама по себе достаточно понятная абстракция, не надо ее заменять на события. Если вам очень не нравятся транзакции — есть Unit of Work.

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


      1. lair
        10.10.2021 17:30
        +2

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

        Так это как раз неправильно. Сервису заказов нужна транзакция. Он, сервис, хочет быть уверенным, что данные консистентны. Так зачем вы это прячете?


        Прошу вас уточнить, что вы подразумеваете под "знает"? Если смотреть только на код модуля core и класс OrderService в частности отсутствует какое-либо знание о том, что будет открыта и зафиксирована транзакция.

        То есть класс OrderService не ожидает, что данные будут консистентны внутри запросов? И не защищается от блокировок, которые могут возникать благодаря транзакциям?


        Тогда зачем вы изначально добавили транзакцию?


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

        … выбрать правильную абстракцию. События обычно плохая абстракция для таких вещей.


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

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


        1. aa0ndrey Автор
          10.10.2021 17:49

          Так это как раз неправильно. Сервису заказов нужна транзакция. Он, сервис, хочет быть уверенным, что данные консистентны. Так зачем вы это прячете?

          Согласен с вами, что если мы посмотрим на всё приложение целиком, то оно должно гарантировать, что данные консистенты и что нужна транзакция. Но вопрос нужно ли нам в основной логике говорить о том, что нужна транзакция?

          Тут зависит от того, что вы хотите видеть в основной логике. Хотите ли вы видеть задачи связанные с управлением транзакцией, решение задачи по трассировке запросов, решение задачи по идемпотентности и реализации request-reply с correlationId в основной логике?

          Если да, то в этом случае вам не стоит прятать через шаблон наблюдателя все эти инфраструктурные задачи.

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

          То есть класс OrderService не ожидает, что данные будут консистентны внутри запросов? И не защищается от блокировок, которые могут возникать благодаря транзакциям?

          Тогда зачем вы изначально добавили транзакцию?

          Идея в том, чтобы в классе OrderService была только основная логика, лишенная инфраструктурных знаний. И в этом смысле это верно, OrderService не пытается решить задачу по консистентности данных. Он делигирует эту задачу в инфраструктурный модуль.

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

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


          1. lair
            10.10.2021 17:57
            +2

            Но вопрос нужно ли нам в основной логике говорить о том, что нужна транзакция?

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


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

            Консистентность — это бизнес-требование, в том-то и дело.


            Идея в том, чтобы в классе OrderService была только основная логика, лишенная инфраструктурных знаний.

            Консистентность — это не инфраструктурное знание.


            В данном примере транзакция слабо повлияла на код в модуле core, чего и хотелось добиться. [...] Могли бы вы превисти пример, который бы продемонстрировал невозможность применения представленных приемов в контексте использования транзакций?

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


            Более того, и вы это упоминаете в посте, чтобы была транзакция, должен быть не только start/end, а еще и откат при ошибке — и это тоже связность, которая все затрудняет.


            1. aa0ndrey Автор
              10.10.2021 18:18

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

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

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

              Консистентность — это не инфраструктурное знание.

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

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

              Прошу вас привести конкретный пример. Если из-за транзакции могут возникнуть дедлоки, не будет ли возможности решить все эти проблемы внутри наблюдателя ...ObserverImpl, так чтобы это существенно не отразилось на основной сервис?

              Более того, и вы это упоминаете в посте, чтобы была транзакция, должен быть не только start/end, а еще и откат при ошибке — и это тоже связность, которая все затрудняет.

              Данный пример, если бы нужно было мог быть расширен дополнительным событием перед отправкой исключения. Например, afterCheckingUserBalanceFailed, для которого в наблюдателе CreateOrderObserverImpl был бы вызван TransactionManagerImpl.rollback()


              1. lair
                10.10.2021 18:26
                -1

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

                Проблема в том, что код OrderService больше не может быть уверенным в этой консистентности.


                OrderService не знает работает ли он вообще в какой-либо конкуренции с кем-либо.

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


                В приведенных примерах нет борьбы против знания о консистентности

                Но у вас больше нет знания о консистентности, у вас есть только знание о событиях, которые вы вызываете.


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

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


                Данный пример, если бы нужно было мог быть расширен дополнительным событием перед отправкой исключения. Например, afterCheckingUserBalanceFailed, для которого в наблюдателе CreateOrderObserverImpl был бы вызван TransactionManagerImpl.rollback()

                … а если у вас случилась произвольная ошибка, откатывать не надо? А ресурсы отпускать тоже не надо?


                И это мы еще не перешли к тому моменту, что вам надо обрабатывать ошибки внутри самих наблюдателей...


                1. aa0ndrey Автор
                  10.10.2021 18:44

                  Проблема в том, что код OrderService больше не может быть уверенным в этой консистентности.

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

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

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

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

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

                  В том числе из-за порядка захвата ресурсов. Если все инфраструктурные запросы выполнять на уровне наблюдателя как это было продемонтрировано в последнем 6 разделе, то появляется возможность контролировать даже порядок запросов не меняя ничего в OrderService. Даже в крайнем случае упорядочевание запросов возможно проводить в OrderService, если не отказываться от интерфейсов репозиториев, при этом никак не раскрывая инфраструктуру.


                  1. aa0ndrey Автор
                    10.10.2021 18:47

                    … а если у вас случилась произвольная ошибка, откатывать не надо? А ресурсы отпускать тоже не надо?

                    Если необходимо обрабатывать все ошибки, в классе OrderService необходимо будет добавить возможность отправки события о том, что произошла ошибка и вызывать событие onFailedOrderCreation в котором можно выполнить rollback.

                    И это мы еще не перешли к тому моменту, что вам надо обрабатывать ошибки внутри самих наблюдателей...

                    Нет никаких препятствий для обработки ошибок внутри наблюдателя. Там есть все знания о текущем процессе + знание инфраструктуры.


                    1. lair
                      10.10.2021 19:11
                      -1

                      Нет никаких препятствий для обработки ошибок внутри наблюдателя.

                      Я имею в виду: сервису надо реагировать на ошибки внутри наблюдателей.


                      1. aa0ndrey Автор
                        10.10.2021 19:18

                        Смотря какую. Если мы получаем, например, что id заказа занят из базы данных. То можно завести соответствующую ошибку на уровне core OrderIdAlreadyUsedException, которая будет обарачивать ошибку от postgres - IntegrityViolationException. Если мы например говорим, что потерялось соединение с базой. То такие ошибки даже не ожидается, что будут решаться на уровне OrderService.

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


                      1. lair
                        10.10.2021 19:22
                        -1

                        Я специально написал: мы еще не перешли к этому моменту (да и не надо особо), потому что это следующий уровень сложности по сравнению с тем, что мы сейчас обсуждаем.


                  1. lair
                    10.10.2021 19:16

                    Почему это проблема?

                    Потому что у нас есть требование строгой консистентности (strong consistency). Если его нет, разговор другой, но я именно из этого требования исхожу.


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

                    Такая возможность — преимущество, да. Я просто считаю, что ваша организация не дает такого преимущества. Почему — ниже.


                    и в значительно меньшей степени на модуль core.

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


                    Если все инфраструктурные запросы выполнять на уровне наблюдателя как это было продемонтрировано в последнем 6 разделе, то появляется возможность контролировать даже порядок запросов не меняя ничего в OrderService.

                    Не выйдет. У вас запросы — это бизнес-логика, если вы их вынесете из OrderService, в нем ничего не останется.


                    Даже в крайнем случае упорядочевание запросов возможно проводить в OrderService, если не отказываться от интерфейсов репозиториев, при этом никак не раскрывая инфраструктуру.

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


                    1. aa0ndrey Автор
                      10.10.2021 19:28

                      Потому что у нас есть требование строгой консистентности (strong consistency). Если его нет, разговор другой, но я именно из этого требования исхожу.

                      Так а разве сделав другую организацию кода, мы теряем возможность удовлетворить требованию? Ведь не стоит же задачи добиться консистентности конкретно в OrderService.

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

                      Потому что решение в отношении 1 к 1. Но а что если добавить ещё дополнительных инфраструктурных задач, которые будут решаться в наблюдателе, вместо OrderService. При масштабировании проблемы, в решении с наблюдателями меняется только кол-во мест в которых происходит отправка. А при прямом использовании интерфейсов вся инфраструктурная логика начинает уходить в основной код.

                      Не выйдет. У вас запросы — это бизнес-логика, если вы их вынесете из OrderService, в нем ничего не останется.

                      В 6 разделе был продемонстрирован пример. При вынесении всех вызовов репозиториев из core модуля в нем останется вся логика, связанная с выполнением бизнес-процесса, все проверки бизнес-требований с соответствующим ветвлением


                      1. lair
                        10.10.2021 19:34
                        -1

                        Так а разве сделав другую организацию кода, мы теряем возможность удовлетворить требованию?

                        Другую по сравнению с чем?


                        Ведь не стоит же задачи добиться консистентности конкретно в OrderService.

                        Стоит задача "операция по созданию заказа должна быть консистентной". Где, как не в OrderService, это искать?


                        При масштабировании проблемы, в решении с наблюдателями меняется только кол-во мест в которых происходит отправка.

                        Нет, еще увеличивается число типов наблюдателей и типов сообщений. Даже на вашем карманном примере их уже то ли три, то ли четыре.


                        В 6 разделе был продемонстрирован пример.

                        В нем порядок запросов все еще контролируется OrderService, поэтому я и говорю, что не выйдет. А представьте, что у вас операций записи больше одной (надо уменьшить число на складе, надо уменьшить баланс, надо создать заказ), как тогда будет выглядеть ваш пример?


                      1. aa0ndrey Автор
                        10.10.2021 19:51

                        Другую по сравнению с чем?

                        Другую по сравнению с тем, чтобы не использовать шаблон наблюдатель

                        Стоит задача "операция по созданию заказа должна быть консистентной". Где, как не в OrderService, это искать?

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

                        Нет, еще увеличивается число типов наблюдателей и типов сообщений. Даже на вашем карманном примере их уже то ли три, то ли четыре.

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

                        В нем порядок запросов все еще контролируется OrderService, поэтому я и говорю, что не выйдет. А представьте, что у вас операций записи больше одной (надо уменьшить число на складе, надо уменьшить баланс, надо создать заказ), как тогда будет выглядеть ваш пример?

                        В CreateOrderObserverImpl можно менять порядок вызовов получения user и product. Это никак не регламентируется OrderService


                      1. lair
                        10.10.2021 20:01
                        -1

                        Другую по сравнению с тем, чтобы не использовать шаблон наблюдатель

                        Я не очень понимаю, что вы хотите сказать.


                        в соответствующем инфраструктурном модуле.

                        … каком?


                        В разделе 4 приведен пример, как избавиться от типов событий, используя всюду контекст.

                        Нет, у вас все еще два события: start и end. То, что у них контекст одного типа, не сильно помогает проблеме.


                        Но при этом в логике нет инфраструктурных знаний.

                        … и нет гарантий.


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

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


                        В CreateOrderObserverImpl можно менять порядок вызовов получения user и product.

                        А, извините, я пропустил, что у вас не lazy.


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


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


                      1. aa0ndrey Автор
                        10.10.2021 20:04

                        Я не очень понимаю, что вы хотите сказать.

                        Если я правильно понимаю, мы сравниваем два решения. С использованием наблюдателя и без. Другая организация кода по отношению к решению без использования наблюдателя.


                      1. lair
                        10.10.2021 20:06

                        Если я правильно понимаю, мы сравниваем два решения. С использованием наблюдателя и без.

                        Мы сравниваем 2+n решений, где одно — базовое в посте (с явным вызовом транзакции), второе — ваше решение с обсервером, а n — все остальное пространство решений данной задачи (например, использование UoW).


                        Пока из этого множества для задачи консистентности решение с наблюдателем наихудшее.


                      1. csl
                        10.10.2021 20:11

                        UoW

                        Unit of Work


                      1. aa0ndrey Автор
                        10.10.2021 20:45

                        Мы сравниваем 2+n решений, где одно — базовое в посте (с явным вызовом транзакции), второе — ваше решение с обсервером, а n — все остальное пространство решений данной задачи (например, использование UoW).

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

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

                        Пока из этого множества для задачи консистентности решение с наблюдателем наихудшее.

                        По вашему субъективному мнению. Если кстати вы говорите, что мы рассматриваем 2+n решений, то я могу привести бесконечное множество решений, которые будут очевидно хуже. Этим я хочу подчеркнуть неуместность разговора о 2+n решениях без конкретики.


                      1. lair
                        10.10.2021 20:57
                        +1

                        Прошу представить пример, UoW чтобы можно было и его сравнивать.

                        Без репозиториев (с UoW они часто излишни):


                        var context = uowProvider.create();
                        
                        var user = context.users.find(request.getUserId());
                        var product = context.products.find(request.getProductId());
                        
                        if (user.getBalance() < product.getPrice()) {
                          throw new RuntimeException("Недостаточно средств");
                        }
                        
                        var order = new Order(UUID.randomUUID(), user.getId(), product.getId());
                        context.orders.create(order);
                        
                        context.commit();

                        С репозиториями (UoW должен лежать в ambient context):


                        var uow = uowProvider.create();
                        
                        var user = userRepository.find(request.getUserId());
                        var product = productRepository.find(request.getProductId());
                        
                        if (user.getBalance() < product.getPrice()) {
                          throw new RuntimeException("Недостаточно средств");
                        }
                        
                        var order = new Order(UUID.randomUUID(), user.getId(), product.getId());
                        orderRepository.create(order);
                        
                        uow.commit();


                      1. aa0ndrey Автор
                        10.10.2021 21:10

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

                        2. Во втором решении не увидел разницы между UoW и TransactionManager


                      1. lair
                        10.10.2021 21:18

                        В первом решении я правильно понимаю, что будет проксироваться использование репозиториев?

                        Нет, там вообще не нужны репозитории как таковые. Контекст предоставляет доступ к доменным данным через, гм, доменные коллекции (они не совсем репозитории).


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

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


                        Собственно, если использовать дженерики (context.repostory<T>), даже добавлять ничего не надо, но в Java для них так себе синтаксис, поэтому я не стал писать пример.


                        Во втором решении не увидел разницы между UoW и TransactionManager

                        Разница в том, что UoW — это паттерн, а транзакция — конкретная имплементация. Собственно, почти любая транзакция — UoW, но не любой UoW — транзакция. Мы уходим от конкретики (транзакция) в пользу абстракции более высокого уровня (UoW), ровно в духе заголовка вашей статьи.


                      1. aa0ndrey Автор
                        10.10.2021 21:46

                        Разница в том, что UoW — это паттерн, а транзакция — конкретная имплементация. Собственно, почти любая транзакция — UoW, но не любой UoW — транзакция. Мы уходим от конкретики (транзакция) в пользу абстракции более высокого уровня (UoW), ровно в духе заголовка вашей статьи.

                        Проблема в том, что не уходим, т.к. UoW принимает команды. А наблюдатель работает с событиями. То есть с UoW вы должны говорить, что надо сделать и передовать для этого конкретные значения. То есть если, мне надо открыть транзакцию с уровнем "read commited" то я должен сделать что-то вроде UoW.begin("read commited");

                        Наблюдатель работает же иначе, мы не говорим, что необходимо сделать, а заявляем что произошло. observer.onStart и уже имплементация знает, что необходимо открыть транзакцию "read commited".

                        Как вы с помощью UoW можете скрыть факт наличия уровня изоляции? А если не можете, то тогда UoW не сильно отличается от TransactionManager.


                      1. lair
                        10.10.2021 21:49
                        -1

                        Проблема в том, что не уходим, т.к. UoW принимает команды.

                        И что? Это все равно абстрация более высокого уровня, чем транзакция.


                        То есть если, мне надо открыть транзакцию с уровнем "read commited" то я должен сделать что-то вроде UoW.begin("read commited");

                        Зачем? Вы можете сказать "начни скоуп", а вот уже имплементация знает, что это означает "открой транзакцию с уровнем read committed".


                        Как вы с помощью UoW можете скрыть факт наличия уровня изоляции? А если не можете, то тогда UoW не сильно отличается от TransactionManager.

                        Точно так же, как вы с помощью обсервера.


                      1. aa0ndrey Автор
                        10.10.2021 20:18

                        … каком?

                        Postgres

                        Нет, у вас все еще два события: start и end. То, что у них контекст одного типа, не сильно помогает проблеме.

                        Нет необходимости создавать множество классов. Добавление каждого нового события означает лишь добавление очередного метода в CreateOrderObserver. Вы утверждали, что при масштабировании становится больше типов, но это не так.

                        … и нет гарантий.

                        Да все верно, OrderService их и не пытается дать. В этом и преимущество, что мы можем отделять разные типы задач.

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

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

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

                        Если понадобиться ещё одна сущность, то да вы правы, тут будет добавлено ещё одно поле в Context и вызов метода для получения этого объекта. Стоит отметить, что именно лишнего вызова репозитория не будет. Он будет перенесен из одного места в другое. Это болейрплейт, который не слишком сложно поддерживать.

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

                        Достоинство - в изоляции от инфраструктуры. Недостатки, как вы уже отметили, это добавление некоторого болейрплейт кода и появление наблюдателей. Стоят ли изменения того или нет, мы, скорее всего, не сойдёмся


                      1. lair
                        10.10.2021 20:24

                        Postgres

                        То есть если у нас две СУБД, то гарантия консистентности будет в двух местах?


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

                        Больше типов событий. Для меня тип события — это ваш метод в интерфейсе. Я просто привык к generic-обработчикам.


                        Это болейрплейт, который не слишком сложно поддерживать.

                        С моей точки зрения — сложно, потому что три изменения (в сервисе, в контексте и в обсервере) вместо одного (в сервисе).


                        Достоинство — в изоляции от инфраструктуры.

                        Изоляция от инфраструктуры достигается с помощью интерфейсов. События для этого не нужны.


                      1. aa0ndrey Автор
                        10.10.2021 20:35

                        То есть если у нас две СУБД, то гарантия консистентности будет в двух местах?

                        Да, каждая БД будет ответственна за свои гарантии или вы предлагаете весь код для каждой СУБД поместить вместе с общей логикой?

                        С моей точки зрения — сложно, потому что три изменения (в сервисе, в контексте и в обсервере) вместо одного (в сервисе).

                        Да это субъективный вопрос и вопрос привычки

                        Изоляция от инфраструктуры достигается с помощью интерфейсов. События для этого не нужны.

                        1. Не достигается. Возьмём пример. Пусть есть один TransactionManager который требует id транзакции для коммита, а другой TransactionManager этого не требует. С прямым интерфейсом вы сможете сделать только один тип решения.

                        2. Вы правильно заметили, что требования по конкурентности могут меняться. Не обязательно даже брать блокировку в самой базе, может быть придётся переделать решение на использование внешнего механизма блокировак на основе Redis? Предлагаете добавить работу с Redis в основной код, через интерфейс? В этом коде тогда будет больше инфраструктурных задач, чем основного кода.


                      1. lair
                        10.10.2021 20:43
                        -1

                        Да, каждая БД будет ответственна за свои гарантии или вы предлагаете весь код для каждой СУБД поместить вместе с общей логикой?

                        Нет, я предлагаю код начала и конца транзакции сделать общим для всех СУБД (спрятав за интерфейсом, конечно же), а вот то, как транзакции сделаны в каждой СУБД, поместить в реализации этого интерфейса.


                        Да это субъективный вопрос и вопрос привычки

                        Три изменения вместо одного — субъективный вопрос?


                        Не достигается. Возьмём пример. Пусть есть один TransactionManager который требует id транзакции для коммита, а другой TransactionManager этого не требует. С прямым интерфейсом вы сможете сделать только один тип решения.

                        Почему не смогу? Метод Begin всегда возвращает объект ConsistencyScope, на котором вызывается CommitEnd), а уже какие данные нужны БД (ид или что-то еще) — лежит внутри этого объекта.


                        Предлагаете добавить работу с Redis в основной код, через интерфейс?

                        Нет, предлагаю заменить реализацию ConsistencyManager, которая использовалась в основном коде, с postgres на redis.


                      1. aa0ndrey Автор
                        10.10.2021 21:03

                        Нет, я предлагаю код начала и конца транзакции сделать общим для всех СУБД (спрятав за интерфейсом, конечно же), а вот то, как транзакции сделаны в каждой СУБД, поместить в реализации этого интерфейса.

                        В предложенном решении также один интерфейс (Observer) и каждая релизация своя у каждой СУБД. В этом смысле не вижу противоречий.

                        Три изменения вместо одного — субъективный вопрос?

                        Количественная метрика очевидна. Вы утверждали, что это сложно. Я ответил, что это вопрос субъективный. Например, использование конкретного типа переменной вместо var для меня не сложно, но очевидно, что символов в конкретном типе больше.

                        Почему не смогу? Метод Begin всегда возвращает объект ConsistencyScope, на котором вызывается Commit (и End), а уже какие данные нужны БД (ид или что-то еще) — лежит внутри этого объекта.

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

                        Нет, предлагаю заменить реализацию ConsistencyManager, которая использовалась в основном коде, с postgres на redis.

                        У вас так не выйдет. Смотрите, когда мы получали данные из postgres, мы могли сделать что-то вроде select for update/share, для user и product. Теперь мы говорим следующее. Что у нас есть внешний арбитр для захвата блокировок через Redis, см. библиотека . То есть через postgres мы получаем данные и открываем его транзакцию, но блокировку мы захватываем в Redis. То есть там будет некоторый код, которые для user-id и product-id захватить блокировку. Прошу привести пример, как это будет реализовано в OrderService.


                      1. lair
                        10.10.2021 21:13
                        -1

                        В предложенном решении также один интерфейс (Observer)

                        Вот только каждый Observer должен не забыть создать транзакцию.


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

                        … и как вы решите эту проблему с помощью Observer?


                        Прошу привести пример, как это будет реализовано в OrderService.

                        А это не будет реализовано в OrderService. Это будет реализовано на уровне репозиториев — они будут брать ConsistenceManager из контекста и захватывать нужные блокировки — так же, как это было в случае репозиториев поверх БД. Причем для этого даже не надо вмешиваться в код исходных репозиториев, достаточно добавить декораторы.


                      1. aa0ndrey Автор
                        10.10.2021 21:36

                        А это не будет реализовано в OrderService. Это будет реализовано на уровне репозиториев — они будут брать ConsistenceManager из контекста и захватывать нужные блокировки — так же, как это было в случае репозиториев поверх БД. Причем для этого даже не надо вмешиваться в код исходных репозиториев, достаточно добавить декораторы.

                        Декораторы не подойдут. Пусть необходимо сначала захватить все необходимые блокировки, а потом уже пытаться получить user и product. То есть:
                        1. lock user-id (redis)
                        2. lock product-id (redis)
                        3. get user (postgres)
                        4. get product (postgres)


                      1. lair
                        10.10.2021 21:50

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

                        Да, такое сделать невозможно. Но это было невозможно и с постгресом, так что паритет.


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


                      1. aa0ndrey Автор
                        10.10.2021 22:06

                        Да, такое сделать невозможно. Но это было невозможно и с постгресом, так что паритет.

                        Не думаю, что паритет. Для решения через наблюдателей, наблюдатели со стороны Redis модуля будут захватывать блокировку, а наблюдатели со стороны Postgres реализовывать получение product и user при этом OrderService останется без изменений.

                        //Redis пакет и модуль
                        class CreateOrderObserverImpl {
                            private RedisClient redisClient;
                            public void onStart(CreateOrderContext context) {
                                redisClient.lock(context.getCommand().getUserId());
                                redisClient.lock(context.getCommand().getProductId());
                            }
                            
                            public void onEnd(CreateOrderContext context) {
                                redisClient.unlock(context.getCommand().getProductId());
                                redisClient.unlcok(context.getCommand().getUserId());
                            }
                        }
                        //Postgres пакет и модуль
                        class CreateOrderObserverImpl {
                            private TransactionManager transactionManager;
                            public void onStart(CreateOrderContext context) {
                                transactionManager.begin("read-commited");
                                //...
                            }
                            
                            public void onEnd(CreateOrderContext context) {
                                //...
                                transactionManager.commit();
                            }
                        }

                        При решении через TransacitonManager или UoM вся логика по захвату блокировок, по выбору уровня изоляции окажется в OrderService.


                      1. lair
                        10.10.2021 22:45

                        Для решения через наблюдателей, наблюдатели со стороны Redis модуля будут захватывать блокировку, а наблюдатели со стороны Postgres реализовывать получение product и user

                        Это если у вас решение, при котором данные читаются из постгреса неявно. По этому поводу я уже все написал ниже: https://habr.com/ru/post/582588#comment_23575496


                        А я сравниваю с решением, где данные берутся из репозитория явно.


                      1. aa0ndrey Автор
                        10.10.2021 21:24

                        Почему не смогу? Метод Begin всегда возвращает объект ConsistencyScope, на котором вызывается Commit (и End), а уже какие данные нужны БД (ид или что-то еще) — лежит внутри этого объекта.

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


                      1. lair
                        10.10.2021 21:27

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

                        Нет, не будет. У меня всегда больше информации и возможностей, чем у наблюдателя.


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

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


                      1. aa0ndrey Автор
                        10.10.2021 21:31

                        Допустим TransactionManagerImpl в методе begin принимает уровень изоляции для постгреса. Тогда

                        class CreateOrderObserverImpl {
                          private TransactionManagerImpl transactionManager;
                        
                          public void onStart(CreateOrderContext context) {
                            transactionManager.begin("REPEATABLE READ");
                          }
                          
                          public void onEnd(CreateOrderContext context) {
                          	transactionManager.commit();
                          }
                        }


                      1. lair
                        10.10.2021 21:48

                        SRSLY.


                        Это всего лишь обозначает, что доступный для OrderService интерфейс TransactionManager не имеет никакого параметра для уровня изоляции, а конкретная реализация TransactionManagerImpl, подставляемая для постгреса, будет передавать REPEATABLE READ всегда.


                      1. aa0ndrey Автор
                        10.10.2021 22:09

                        Не понял вас. То есть TransactionManager будет уметь работать только с одним уровнем изоляции? Или как включать тогда любой другой уровень изоляции, если в реализации TransactionManager всегда будет repeatable read ?


                      1. lair
                        10.10.2021 22:45

                        То есть TransactionManager будет уметь работать только с одним уровнем изоляции?

                        Доступный OrderService — да.


                        Или как включать тогда любой другой уровень изоляции, если в реализации TransactionManager всегда будет repeatable read ?

                        А как из OrderService задать любой другой уровень изоляции в вашем решении с обсервером?


                      1. aa0ndrey Автор
                        10.10.2021 21:32

                        Нет, не будет. У меня всегда больше информации и возможностей, чем у наблюдателя.

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


                      1. lair
                        10.10.2021 21:52

                        Информации не больше, так как в наблюдателя можно передать всю необходимую информацию.

                        Это нарушит абстрацию наблюдателя, потому что (предположительно) наблюдатель отвечает не только за консистентность. А вот менеджер консистентности — только за нее, и у него есть конкретный более узкий контракт.


                        А возможностей ровно столько, сколько вы смогли сделать в рамках обобщения.

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


                      1. aa0ndrey Автор
                        10.10.2021 22:11

                        Это нарушит абстрацию наблюдателя, потому что (предположительно) наблюдатель отвечает не только за консистентность. А вот менеджер консистентности — только за нее, и у него есть конкретный более узкий контракт.

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

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

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


                      1. aa0ndrey Автор
                        10.10.2021 22:30

                        Я говорю про то, что ваше решение будет примерно таким:

                        Для явных интерфейсов

                        class OrderService {
                            private TransactionManager transactionManager;
                            private LockManager lockManager;
                            public void create(CreateOrderRequest request) {
                                lockManager.lock(request.getUserId());
                                lockManager.lock(request.getProductId());
                                var consistencyContext = transactionManager.begin("READ COMMITTED");
                                
                                //domain code
                                
                                lockManager.unlock(request.getProductId());
                                lockManager.unlock(request.getUserId());
                                transactionManager.commit(consistencyContext);
                            }
                        }

                        Для UoM

                        class OrderService {
                            public void create(CreateOrderRequest request) {
                                var uomContext = UoM.create();
                                uomContext.lock(request.getUserId());
                                uomContext.lock(request.getProductId());
                                uomContext.begin("READ COMMITTED");
                                
                                //domain code
                        
                                uomContext.unlock(request.getProductId());
                                uomContext.unlock(request.getUserId());
                                uomContext.commit();
                            }
                        }

                        Обратите внимание, что для uomContext вы как то укажите уровень изоляции. Это уже раскроет деталь реализации. Также вы будете явно захватывать блокировки.

                        1. UoM почти не отличается от использования интерфейсов TransactionManager и LockManager. Поэтому утверждение о том, что UoM скрывает детали реализации неверно.

                        2. В обоих примерах всё больше деталей проникает в основной код. Ситуацию можно ещё сильнее усугубить добавив очереди сообщений с соответствующими задачами оттуда


                      1. lair
                        10.10.2021 22:48
                        -1

                        Обратите внимание, что для uomContext вы как то укажите уровень изоляции.

                        Нет, не укажу, конечно же. Это не нужно.


                        UoM почти не отличается от использования интерфейсов TransactionManager и LockManager.

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


                      1. lair
                        10.10.2021 22:47
                        -1

                        Наблюдателю могут быть переданы любые объекты, появляющиеся в основном методе.

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


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

                        Нет, зачем? Не должен, конечно.


            1. LaRN
              18.10.2021 21:04
              +1

              Но ведь транзакции не каждая БД поддерживает и если абстрагироваться об особенностей БД, то в общем случае нужно абстрагироваться и от транзакции.

              Разве не так?


              1. lair
                18.10.2021 22:14

                Нет, не так. Вы не можете абстрагироваться от требований консистентности.


                Ваша бизнес-логика либо не требует консистентности в двух подряд идущих операциях (и тогда вам не нужны транзакции), либо она ее требует — и тогда вы просто перестаете поддерживать те БД, которые не поддерживают нужные вам транзакции.


                Смысл именно в том, что вот это вот ожидание в бизнес-логике — оно очень важно, и его нельзя оставлять на откуп наблюдателям.


                1. LaRN
                  18.10.2021 22:25

                  Транзакции не единственный способ обеспечить консистентность. Есть например паттерн "сага".


                  1. lair
                    18.10.2021 22:26

                    И что же, бизнес-код для этого паттерна ничем не отличается от кода, который полагается на транзакции?


                    Давайте вот на простом примере. Заказ не должен быть создан, если пользователь не активен. Вот простой пример "под транзакцией". А в "саге" как?


                    user = userRepository.getUser(id);
                    if (user.enabled)
                      orderRepository.createOrder();


                    1. LaRN
                      18.10.2021 22:43

                      А зачем тут транзакция? Активность пользователя можно проверить до того как начать что-то делать. В транзакцию по идее нужно заворачивать код который меняет данные сразу в нескольких сущностях. Если вообще весь код завернуть в транзакцию со всеми проверка, то это может привести к тому что относительно долгое время жизни открытой транзакции в высоконагруженной системе приведёт к быстрому росту лога транзакции и к замедление всех операций в БД.


                      1. lair
                        18.10.2021 22:45

                        А зачем тут транзакция? Активность пользователя можно проверить до того как начать что-то делать.

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


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

                        Ну вот допустим мы создаем заказ и одновременно меняем доступное количество товара. "Под транзакцией" этот код прост и понятен — уменьшили количество товара, проверили, что остаток больше нуля, создали заказ. К моменту заказа количество товара измениться уже не может. С сагой код не меняется?


                      1. LaRN
                        19.10.2021 09:52

                        То о чем вы пишите - это просто наложить блокировку на объект на время операции. Зачем тут транзакция?

                        Ну ведь не обязательно товары и юзеры у вас в одном сервисе живут.

                        Из метода userRepository.getUser(id)

                        может улететь rest запрос в другой сервис например и как тут транзакция поможет?


                      1. lair
                        19.10.2021 09:56

                        То о чем вы пишите — это просто наложить блокировку на объект на время операции. Зачем тут транзакция?

                        … вот поэтому я в ходе дискуссии и переключился с понятия "транзакция" на понятие "граница консистентности".


                        Ну ведь не обязательно товары и юзеры у вас в одном сервисе живут.

                        Не обязательно. И если они не живут, то некоторые гарантии недостижимы.


                        Из метода userRepository.getUser(id) может улететь rest запрос в другой сервис например и как тут транзакция поможет?

                        Никак не поможет. Это означает, что код, который ожидает, что после getUser изменения невозможны, корректно работать не будет.


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


          1. lair
            10.10.2021 18:43
            +1

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

            observers.forEach(observer -> observer.onStart(startEvent));
            
            //some domain code
            
            observers.forEach(observer -> observer.onEnd(endEvent));

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


            1. aa0ndrey Автор
              10.10.2021 18:52

              Подскажите вы имеет ввиду проблему связанную с возможными ошибками? Если да, то об этом в статье упомяналось. Обработка ошибок намерено не была добавлена, чтобы не усложнять пример.

              try {
                observers.forEach(observer -> observer.onStart(startEvent));
              
                //some domain code
              
                observers.forEach(observer -> observer.onEnd(endEvent));
              } catch (Exception e) {
                observers.forEach(observer -> observer.onOrderCreationFailed(e);
              }

              Есть ли какие-либо противоречия с таким решением?


              1. lair
                10.10.2021 19:10

                Подскажите вы имеет ввиду проблему связанную с возможными ошибками?

                Нет, я совсем не об этом.


                1. aa0ndrey Автор
                  10.10.2021 19:14

                  Тогда я вас попрашу показать, так как мне не удается увидеть проблему


                  1. lair
                    10.10.2021 19:17
                    +1

                    Предположим, что один из обсерверов должен выполнять аудит — т.е. писать в БД "начата обработка запроса на создание заказа" и "заказ успешно создан, id такой-то". Это совершенно типовой сценарий для этого паттерна.


                    В какой момент по отношению к транзакции будет выполнен этот обсервер?


                    1. aa0ndrey Автор
                      10.10.2021 19:42

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

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

                      Во-вторых, если по какой-то причине аудит был вынесен из postgres модуля, что странно, то эта задача остается решаемой.

                      1. Нет проблем, чтобы создать на отдельный тип событий свой список наблюдателей, чтобы решить подобные коллизии

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

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


                      1. lair
                        10.10.2021 19:47

                        Если у нас аудит находится в модуле postgres, то вся эта логика будет выполняться внутри одного наблюдателя в postgres модуле.

                        Нет, это очевидно разные, независимые наблюдатели. Аудит — это не уровень БД, он должен для всех СУБД работать.


                        Нет проблем, чтобы создать на отдельный тип событий свой список наблюдателей, чтобы решить подобные коллизии

                        Покажите пример кода, потому что я вас не очень понимаю.


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

                        … а кто и где у вас порядком наблюдателей управляет?


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

                        Для этого аудит должен знать о постгресе, что недопустимо (выше написано, почему).


                      1. aa0ndrey Автор
                        10.10.2021 19:59

                        Нет, это очевидно разные, независимые наблюдатели. Аудит — это не уровень БД, он должен для всех СУБД работать.

                        Тут опять необходимо конкретика. Как аудит подключается? Прошу привести пример. Аудит может быть и отдельной библиотекой для конкретной СУБД интегрированной с общим решением. Также прошу превести пример, как бы и куда вы его поместили.

                        Покажите пример кода, потому что я вас не очень понимаю.

                        class OrderService {
                          //.. другие поля
                          List<CreateOrderObserver.OnEnd> onEndObservers;
                          List<CreateOrderObserver.OnStart> onStartObserversl
                        }

                        … а кто и где у вас порядком наблюдателей управляет?

                        В примерах неуказано, но вообще с помощью IoC контейнера

                        Для этого аудит должен знать о постгресе, что недопустимо (выше написано, почему).

                        Не обязательно. Аудит можно подключать через адаптер, который будет мостом между postgres и аудитом


                      1. lair
                        10.10.2021 20:03

                        Как аудит подключается?

                        А как подключаются обсерверы в вашем посте?


                        Также прошу превести пример, как бы и куда вы его поместили.

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


                        class OrderService {
                        //.. другие поля
                        List<CreateOrderObserver.OnEnd> onEndObservers;
                        List<CreateOrderObserver.OnStart> onStartObserversl
                        }

                        Именно это я и имел в виду под "растет число наблюдателей". Учитывая, что сущностей у вас реалистично много, на каждую вам надо настроить порядок в n раз большего числа обсерверов — это становится тяжело в поддержке, без очевидных достоинств.


                      1. aa0ndrey Автор
                        10.10.2021 20:20

                        Смотря от контекста. Если всего два события, а очень часто так и бывает, то можно увеличивать число типов Observer-ов. Но никто не отменял решения: 2. и 3. предложенные ранее.


                      1. lair
                        10.10.2021 20:26

                        Равно как и никто не отменял их недостатки: тяжесть конфигурации и взаимную зависимость обсерверов (что, на самом деле, очень плохо).


                      1. dopusteam
                        10.10.2021 23:21
                        -1

                        В примерах неуказано, но вообще с помощью IoC контейнера

                        Простите, что вклиниваюсь в увлекательнейший диалог, но это жесть какая то, нет?


                      1. lair
                        10.10.2021 23:24

                        Учитывая, сколько этих обсерверов будет, и то, что им нужна ручная конфигурация порядка — да, жесть.


                      1. aa0ndrey Автор
                        11.10.2021 02:08

                        Учитывая, сколько этих обсерверов будет, и то, что им нужна ручная конфигурация порядка — да, жесть.

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

                        Во второй части будет представлен дополнительный application модуль, который исключит необходимость использования наблюдателей в большинстве случаев. Но при этом при необходимости организовать вызов инфраструктурного интерфейса где-то посередине метода основной логики, будет выгодно использование именно наблюдателя.


                      1. lair
                        11.10.2021 09:21

                        Таких наблюдателей не будет много. Это больше редкий случай, чем частая практика.

                        … то есть эта вся конструкция не расчитана на "нам надо добавить транзакционность во все бизнес-операции"?


  1. lair
    10.10.2021 21:58
    +1

    И отдельно по поводу (6):


    public class OrderService {
        private final List<CreateOrderObserver> observers;
    
        public void create(CreateOrderContext context) {
            observers.forEach(observer -> observer.onStart(context));
    
            var user = context.getUser();
            var product = context.getProduct();
    
            if (user.getBalance() < product.getPrice()) {
                throw new RuntimeException("Недостаточно средств");
            }
    
            var order = new Order(UUID.randomUUID(), user.getId(), product.getId());
            context.setCreatedOrder(order); //(3)
    
            observers.forEach(observer -> observer.onEnd(context));
        }
    }
    
    public class CreateOrderObserverImpl implements CreateOrderObserver {
        private final TransactionManagerImpl transactionManagerImpl;
        private final ThreadLocal<Long> transactionId = new ThreadLocal<>();
        private final UserRepositoryImpl userRepository;
        private final ProductRepositoryImpl productRepository;
        private final OrderRepositoryImpl orderRepository;
    
        @Override
        public void onStart(CreateOrderContext context) {
            transactionId.set(transactionManagerImpl.begin());
            var request = context.getRequest();
            context.setUser(userRepository.find(request.getUserId()));
            context.setProduct(productRepository.find(request.getProductId()));
        }
    
        @Override
        public void onEnd(CreateOrderContext context) {
            transactionManagerImpl.commit(transactionId.get());
            orderRepository.create(context.getCreatedOrder());
        }
    }

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


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


    1. aa0ndrey Автор
      10.10.2021 22:51

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

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

      1. Указать явно уровень изоляции при открытии транзакции

      2. Добавить захват блокировок из другой базы данных, например Redis.

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

      Также мною было продемонстрировано предположение о том, что будет сделано если использовать конкретные интерфейсы или UoM (позицию которую вы защищаете) тут

      Этими примерами, мне кажется, что удалось продемонстрировать:

      1. UoM не скрывает детали реализации лучше чем TransactionManager

      2. Основная логика начинает обрастать инфраструктурными деталями, если использовать UoM или TransactionManager.

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

      Использование событий - это решение, которое позволяет избавиться от лишних знаний.


      1. lair
        10.10.2021 22:59

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

        "Основная логика" включает в себя получение и сохранение данных, у вас этого "в одном месте" нет.


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

        А так же, как и раньше. Просто у ConsistencyManager появляется метод createScopeFor<Context>(), который и делегирует всю работу куда положено.


        Также мною было продемонстрировано предположение о том, что будет сделано если использовать конкретные интерфейсы или UoM (позицию которую вы защищаете)

        Это предположение неверно. Не надо передавать никакие специфичные параметры в UoW.


        UoM не скрывает детали реализации лучше чем TransactionManager

        Задача UoW не в том, чтобы "лучше скрывать детали реализации". Его задача в том, чтобы предоставить другую абстракцию.


        Вам нужно знать об уровнях изоляции, о необходимости захвата блокировок, о наличии скоупа транзакции.

        Нет, не нужно. Я не понимаю, откуда вы это берете.


        Использование событий — это решение, которое позволяет избавиться от лишних знаний.

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


        1. aa0ndrey Автор
          10.10.2021 23:07

          Тогда я построил примеры, которые видимо как-то отличаются от того, что вы пытаетесь объяснить. Я прошу вас показать, как с помощью TransactionManager и/или UoM будет решены обе эти задачи в OrderService. Иначе мне сложно понять, что вы предлагаете. Под задачами я понимаю, что:

          1. Необходимо как-то указать конкретный уровень изоляции для транзакции

          2. Необходимо добавить блокировку данных в другой базе


          1. lair
            10.10.2021 23:11

            А давайте начнем с простого вопроса: как вы то же самое решаете в наблюдателях?


            1. aa0ndrey Автор
              10.10.2021 23:15

              public class OrderService {
                  private final List<CreateOrderObserver> observers;
              
                  public void create(CreateOrderContext context) {
                      observers.forEach(observer -> observer.onStart(context));
              
                      var user = context.getUser();
                      var product = context.getProduct();
              
                      if (user.getBalance() < product.getPrice()) {
                          throw new RuntimeException("Недостаточно средств");
                      }
              
                      var order = new Order(UUID.randomUUID(), user.getId(), product.getId());
                      context.setCreatedOrder(order); //(3)
              
                      observers.forEach(observer -> observer.onEnd(context));
                  }
              }
              
              //Redis пакет и модуль
              class CreateOrderObserverImpl {
                  private RedisClient redisClient;
                  public void onStart(CreateOrderContext context) {
                      redisClient.lock(context.getRequest().getUserId());
                      redisClient.lock(context.getRequest().getProductId());
                  }
                  
                  public void onEnd(CreateOrderContext context) {
                      redisClient.unlock(context.getRequest().getProductId());
                      redisClient.unlcok(context.getRequest().getUserId());
                  }
              }
              
              //Postgres пакет и модуль
              class CreateOrderObserverImpl {
                  private TransactionManager transactionManager;
                  public void onStart(CreateOrderContext context) {
                      transactionManager.begin("read-commited");
                      var request = context.getRequest();
                      context.setUser(userRepository.find(request.getUserId()));
                      context.setProduct(productRepository.find(request.getProductId()));
                  }
                  
                  public void onEnd(CreateOrderContext context) {
                      orderRepository.create(context.getCreatedOrder());
                      transactionManager.commit();
                  }
              }


              1. lair
                10.10.2021 23:18

                Я правильно понимаю, что интерфейс CreateOrderObserver напрямую привязан к OrderService.create, и нигде больше быть использован не может? Иными словами, один бизнес-метод — один интерфейс наблюдателя?


                1. aa0ndrey Автор
                  10.10.2021 23:20

                  Я правильно понимаю, что интерфейс CreateOrderObserver напрямую привязан к OrderService.create, и нигде больше быть использован не может? Иными словами, один бизнес-метод — один интерфейс наблюдателя?

                  Да, верно


                  1. lair
                    10.10.2021 23:23

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


                    var scope = consistencyManager.beginScope();
                    
                    var user = userRepository.find(request.getUserId());
                    var product = productRepository.find(request.getProductId());
                    
                    if (user.getBalance() < product.getPrice()) {
                      throw new RuntimeException("Недостаточно средств");
                    }
                    
                    var order = new Order(UUID.randomUUID(), user.getId(), product.getId());
                    orderRepository.create(order);
                    
                    scope.commit(); 

                    С UoW — аналогично.


                    1. aa0ndrey Автор
                      10.10.2021 23:27

                      1. Покажите где будет лежать реализация, в каком модуле?

                        Пусть есть 3 модуля: core, postgres и redis.

                      2. Приведите пример его реализации.

                      3. Как consistencyManager узнает какие userId и productId необходимо заблокировать?


                      1. lair
                        10.10.2021 23:37

                        Покажите где будет лежать реализация, в каком модуле?
                        Пусть есть 3 модуля: core, postgres и redis.

                        В модуле core, опираясь на отдельные интерфейсы LockManager и TransactionManager.


                        Приведите пример его реализации.

                        Метод beginScope:


                        var scope = new Scope();
                        scope.addLock(lockManager.lock(request.getUserId()));
                        scope.addLock(lockManager.lock(request.getProductId()));
                        scope.setTransaction(transactionManager.beginReadCommitted());
                        return scope;

                        Как consistencyManager узнает какие userId и productId необходимо заблокировать?

                        Забыл указать, что ConsistencyManager.beginScope принимает параметр CreateOrderRequest request. Так что оттуда же, откуда и наблюдатели.


                        Хотя будем честными, это все намного проще сделать через дженерики: ConsistencyManager<T>, LockManager<T> и TransactionManager<T>, но это будет неконсистентно с вашими недженеричными наблюдателями.


                      1. aa0ndrey Автор
                        10.10.2021 23:45

                        В модуле core, опираясь на отдельные интерфейсы LockManager и TransactionManager.

                        Если СonsistencyManager c его методами, которые опираются на интерфейсы TransactionManager и LockManager находится в core, то и интерфейсы TransactionManager и LockManager тоже находятся в core.

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

                        Такого же эффекта можно было добиться добавив приватные методы begin и commit в OrderService.


                      1. lair
                        10.10.2021 23:49

                        интерфейсы TransactionManager и LockManager тоже находятся в core.

                        Да, и что?


                        То есть из core модуля все также происходит управление транзакциями и блокировками.

                        Да, и что?


                        Это код, который не должен зависеть от конкретной реализации СУБД или механизма блокировок.


                        Хотите — вынесите его в отдельный модуль consistency, но это будет модуль ради модуля.


                        Такого же эффекта можно было добиться добавив приватные методы begin и commit в OrderService.

                        Нет, я не хочу, чтобы класс, отвечающий за бизнес-логику, знал такие мелкие детали.


                        Встречный вопрос: а где лежит код, который определяет нужный список наблюдателей для каждого бизнес-сервиса?


                      1. aa0ndrey Автор
                        11.10.2021 00:01

                        Да, и что?

                        А то, что вы начинаете брать на себя ответственность за поддержку обоих интерфейсов. Ещё возможно, если вы захотите сделать общие решения, вам придётся очень сильно постараться в плане обобщения.

                        Задача обобщения решения для множества альтернатив не самая приятная. А если вы ошибетесь, то у вас будут интерфейсы, которые очень хорошо мапятся на конкретную библиотеку. Но тогда почему бы просто не подключить postgres в core и все выносить в ConsistencyManager в том же модуле?

                        Это будет аналогично, так как ваши интерфейсы при ошибки в обобщении не спасут вас от деталей.

                        Это код, который не должен зависеть от конкретной реализации СУБД или механизма блокировок.

                        Это ограничение на предложенное вами решение. Если такие обобщения невозможно сделать, то у вас будут интерфейсы, которые мапятся только на выбранную СУБД.

                        ---

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


                      1. lair
                        11.10.2021 00:06

                        А то, что вы начинаете брать на себя ответственность за поддержку обоих интерфейсов.

                        Не более, чем за поддержку интерфейсов наблюдателей.


                        Это ограничение на предложенное вами решение.

                        Нет, это причина, почему я делаю такое решение — потому что я не хочу дублировать код блокировок между разными СУБД.


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

                        Нет, фундаментальная разница не в том, команда это или событие, а в том, что CreateOrderConsistencyManager отвечает только и исключительно за консистентность, и он обязан существовать, а CreateOrderObserver отвечает неизвестно за что, и их неизвестно сколько.


                        Иными словами, в случае с менеджером бизнес-код точно знает, на что он опирается, а в случае с наблюдателем — нет. И это именно то, с чего я начал.


                      1. aa0ndrey Автор
                        11.10.2021 00:20

                        Не более, чем за поддержку интерфейсов наблюдателей.

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

                        При этом в решении с наблюдателями, если бизнес-логика не изменилась, то скорее всего изменятся только реализации наблюдателей в соответствующих модулях

                        Нет, это причина, почему я делаю такое решение — потому что я не хочу дублировать код блокировок между разными СУБД.

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

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

                        Иными словами, в случае с менеджером бизнес-код точно знает, на что он опирается, а в случае с наблюдателем — нет. И это именно то, с чего я начал.

                        Не понимаю вас. Что означает бизнес-код точно знает? Необходимо, чтобы разработчик точно знал. В строготипизированных языках программирования перейти от интерфейса к реализации можно за один шаг. Поэтому не будет особой разницы для понимания, если вы перейдете к CreateOrderConsistencyManager или к CreateOrderObserverImpl.


                      1. lair
                        11.10.2021 00:26

                        Интерфейсы для инфраструктрных интерфейсов могут меняться например при смене реализации или при необходимости использовать доп. возможность.

                        Да, это нормально.


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

                        То же самое и с интерфейсом наблюдателя, не вижу разницы.


                        Также вы себя ограничиваете в возможностях.

                        Да. Потому что я хочу писать прозрачный и читаемый код. Это требует ограничения возможностей.


                        Не понимаю вас. Что означает бизнес-код точно знает?

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


                        В строготипизированных языках программирования перейти от интерфейса к реализации можно за один шаг. Поэтому не будет особой разницы для понимания, если вы перейдете к CreateOrderConsistencyManager или к CreateOrderObserverImpl.

                        Я повторю свой вопрос: где задается список и порядок наблюдателей?


                      1. aa0ndrey Автор
                        11.10.2021 00:53

                        То же самое и с интерфейсом наблюдателя, не вижу разницы.

                        Ранее приводил пример. Он, конечно, искуственный, но тем не менее. Пусть был интерфейс: TransactionManager.commit();
                        А затем он стал: TransactionManager.commit(transactionId);
                        В этом случае пострадают все ConsistencyManager в core модуле. А также TransactionManagerImpl в postgres модуле.

                        В случае наблюдателей пострадают TransactionManager и все ObserverImpl только в postgres модуле.

                        Да. Потому что я хочу писать прозрачный и читаемый код. Это требует ограничения возможностей.

                        Не согласен с вашим подходом. Вы либо будете себя ограничивать в каких-то возможностях, либо будете продумывать, как решения можно обобщить, либо не будете этим заниматься и где-то ошибетесь.

                        Хочу вот еще что заметить. Попытки обобщить какие-либо технологии принимались и принимаются множество раз. Например, различные ORM-технологии, те же TransactionManager, SQL стандарт и т.д. Но всюду эти решения обладают одним общим недостатком.

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

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

                        Ваш же подход предполагает создание обобщений и работу с ними. В таком подходе вы либо тратите время и отказываетесь от чего-то, чтобы построить правильные интерфейсы, либо они у вас будут очень похожи на конкретную библиотеку. И тогда толку от того, что у вас будет отделен модуль core от postgres мало.

                        Я повторю свой вопрос: где задается список и порядок наблюдателей?

                        В приведенном примере нигде не задается. Об этом будет вторая часть. Там будет добавлен модуль application, который создает список наблюдателей.
                        Примерно такой код:

                        @Configuration
                        class OrderConfiguration {
                        	private final List<CreateOrderObserver> observers;
                          
                          @Bean
                          public OrderService orderService() {
                            return new OrderService(
                            	observers
                            );
                          }
                        }

                        При этом для CreateOrderObserverImpl будет добавлена аннотация @Order:

                        @Order(1)
                        @Component
                        class CreateOrderObserverImpl implements CreateOrderObserver {
                        }


                      1. lair
                        11.10.2021 01:01

                        В случае наблюдателей пострадают TransactionManager все ObserverImpl только в postgres модуле.

                        А идентификатор вы откуда возьмете для коммита? Из тред-контекста? Так я могу то же самое сделать в имплементации менеджера в постгресе. Из прокидываемого между вызовами параметра? Так это надо было заранее заложить, и если вы забыли заложить, то вам точно так же придется все менять.


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

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


                        Повторюсь, это специальное осознанное решение. Explicit is better than implicit.


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

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


                        Ваш же подход предполагает создание обобщений и работу с ними.

                        Абстракций, да. Вы определение DI(P) помните?


                        Там будет добавлен модуль application, который создает список наблюдателей.

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


                      1. aa0ndrey Автор
                        11.10.2021 01:20

                        А идентификатор вы откуда возьмете для коммита? Из тред-контекста? Так я могу то же самое сделать в имплементации менеджера в постгресе. Из прокидываемого между вызовами параметра? Так это надо было заранее заложить, и если вы забыли заложить, то вам точно так же придется все менять.

                        Но ничего не придётся менять в модуле core.

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

                        Повторюсь, это специальное осознанное решение. Explicit is better than implicit.

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

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

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

                        Абстракций, да. Вы определение DI(P) помните?

                        Предложенное решение никак не противоречит DI(P). Мне кажется, данный вопрос провакационный и лишний с вашей стороны.

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

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


                      1. lair
                        11.10.2021 09:27

                        Но ничего не придётся менять в модуле core

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


                        Аналогично и если я не забыл, что у транзакции или блокировки есть состояние, ничего в core менять не придется.


                        Кстати говоря, в большинстве случаев такие обобщения не справляются.

                        Это утверждение несколько подрывает DI(P), нет?


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

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


                        Но вообще меня удивляет, конечно: "большинство обобщений не справляется", но вот ваше решение — которое тоже обобщение! — справляется.


                        Предложенное решение никак не противоречит DI(P).

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


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

                        И все их надо просмотреть, а потом понять порядок их вызовов.


                        Обычно их не будет более одной или двух.

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


                      1. aa0ndrey Автор
                        11.10.2021 11:35

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

                        Аналогично и если я не забыл, что у транзакции или блокировки есть состояние, ничего в core менять не придется.

                        Только вот ваши изменения будут зависеть от инфраструктурных изменений/ошибок. Зачем используется DI(P)? Чтобы снизить зависимость одно модуля от другого. Если в вашем подходе приходится чаще вносить изменения в core из-за каких-либо инфраструктурных изменений, значит он хуже справляется.

                        Это утверждение несколько подрывает DI(P), нет?

                        Нет, утверждение подрывает успешность создания конкретного интерфейса. Если вы посмотрите в книжку Clean Code, то в главе про DIP есть раздел про стабильные абстракции. Вы предлагаете делать абстракцию, которая близка к реализации. На что я подмечаю, что она менее стабильна, чем абстракция построенная на событиях. Наблюдатель также использует связку интерфейс-реализация, но более стабилен как абстракция.

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

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

                        Но вообще меня удивляет, конечно: "большинство обобщений не справляется", но вот ваше решение — которое тоже обобщение! — справляется.

                        Да, потому что мое решение не ставит целью подвести напрямую все технологии под общий интерфейс

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

                        Создание стабильных абстракций. Вашему решению я ставлю в вину стабильность.

                        И все их надо просмотреть, а потом понять порядок их вызовов.

                        Да, для этого достаточно всего лишь посмотреть на цифру в аннотации @Order

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

                        В моем примере их максимум 3. Два для блокировок и один для транзакций.

                        Аудит не должен быть частью общего процесса. Он должен уже "подключаться" (через мост, абстракции и т.д.) к самим модулям postgres/redis и через их интерфейс/абстракции получать информацию. Аудит про интеграцию с базами, а не с модулем core.

                        ---

                        Смотрите, чем ваше решение мне не нравится. Вы создаете интерфейс в модуле core. При этом:

                        1. Вы себя ограничиваете

                        2. Вы допускаете возможность ошибиться

                        3. Вы создаете менее стабильную абстракцию.

                        Ваши интерфейсы это иллюзия. Например, в MongoDb долгое время не было транзакций на несколько объектов. Создав интерфейс TransactionManager#beginRepeatableRead() вы обманываете себя, что ваш интерфейс подойдёт к замене с MonogoDb. И если делать такую замену, то вы будете править ваш TransactionManager.beginRepeatableRead() потому что ваш интерфейс не сможет дать гарантии RepeatableRead().

                        Далее мы говорили, например, что может понадобиться использовать Redis для того, чтобы вынести задачу по захвату блокировок. Новое изменение заставит вас внести новый интерфейс LockManager, что опять приведет к правкам в core модуле.

                        Дальше, например, вы выравниваете ваш LockManager в соответствии с библиотекой redisson и используете возможность создания redlock, которые создаются на определенный промежуток времени.

                        Но потом, вы решили отказаться от redisson и для блокировок решили использовать advisory lock, который есть в postgres. Только вот он не умеет создаваться на определенный промежуток времени, но зато умеет привязываться к транзакции. И вы опять поменяете LockManager. И вы опять сделаете правки в модуле core.

                        Дальше представим, что мы используем postgres и для него использование уровня транзакции SERIALIZABLE предпочтительнее использования блокировок, потому что там используется MVCC. Поэтому на уровне core вы можете потенциально для postgres использовать именно этот уровень изоляции.

                        Но если по какой-то причине вы поменяете реализацию с postgres на другую, в которой нет реализации SERIALIZABLE с помощью MVCC, то вам придётся всюду, где вы использовали вместо блокировок уровень изоляции SERIALIZABLE поменять на использование блокировок типа for update/for share любо другие аналоги, т.к. использование SERIALIZABLE для другой БД это может быть антипаттерном. И вы опять внесёте изменения в модуль core.

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

                        Но все обычно становится куда хуже. В большинстве случаев все эти задачи по управлению транзакциями, блокировками, решению задач с очередями не будет красиво спрятаны в ваш класс CreateOrderManagerService. Если интерфейс доступен на уровне core модуля, то он будет напрямую использоваться в коде core модуля.

                        Инфраструктурные знания расползутся по коду, их будет потом не собрать.


                      1. lair
                        11.10.2021 11:39
                        -1

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


                        Моя абстракция более стабильна в части бизнес-логики. Иными словами, она меняется тогда, когда меняется инфраструктура (=редко), а не тогда, когда меняется бизнес-логика (=часто). У вас — наоборот, ваша абстракция не меняется тогда, когда меняется инфраструктура, но меняется тогда, когда меняется бизнес-логика.


                      1. lair
                        11.10.2021 13:46

                        Я перечитал еще раз, и подумал, что писать развернутый комментарий нет смысла, потому что вы опираетесь на ошибочное предположение. Ваше предположение состоит в том, что мое решение — это совокупность всего, что я раньше вам накидал в ответ на разные ваши "а что если". Но на самом деле, ключевая часть моего решения — вот:


                        var scope = consistencyManager.beginScope();
                        
                        var user = userRepository.find(request.getUserId());
                        var product = productRepository.find(request.getProductId());
                        
                        if (user.getBalance() < product.getPrice()) {
                          throw new RuntimeException("Недостаточно средств");
                        }
                        
                        var order = new Order(UUID.randomUUID(), user.getId(), product.getId());
                        orderRepository.create(order);
                        
                        scope.commit(); 

                        Иными словами, важно, что граница консистентности явно задана в коде (я исхожу из того, что она не всегда совпадает с границей метода, потому что иначе можно было бы уже перейти на Command/Handler pattern и унести консистентность в промежуточный обработчик). Все остальное — детали реализации. Если я когда-нибудь обнаружу себя в позиции, когда мне надо поддерживать такую сложную и меняющуюся инфраструктуру, как вы описываете ("а вот тут постгрес, а вот тут — редис, а вот тут еще что-то"), я… спрячу это в ConsistencyManagerImpl:


                        public Scope beginScope() {
                          var scope = new Scope();
                          scope.setHandlerState(consistencyHandlers.map(h -> h.begin(scope)));
                          return scope;
                        }
                        
                        // Scope
                        public void Close() {
                          handlerState.reverse().forEach(s -> s.close(this));
                        }

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


                        public Scope beginScope(T request) {
                          var consistencyHandlers = consistencyHandlerProvider.get(request.class);
                          var scope = new Scope(request);
                          // дальше осталось как было
                        }

                        Эксплицитность поведения бизнес-метода при этом никуда не денется.


                      1. aa0ndrey Автор
                        11.10.2021 15:26

                        Я перечитал еще раз, и подумал, что писать развернутый комментарий нет смысла, потому что вы опираетесь на ошибочное предположение. Ваше предположение состоит в том, что мое решение — это совокупность всего, что я раньше вам накидал в ответ на разные ваши "а что если". Но на самом деле, ключевая часть моего решения — вот:

                        Это кстати демонстрирует потенциальный ход развития принимаемых вами решений. То есть при столкновении с очередным, "что если?" вы меняете достаточно сильно решения. И что важно вы это делаете в модуле core.

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

                        Тогда в ваших интерфейсах мало смысла, если вы их помещаете в модуль core, то вы тем самым можете сказать, что у вас не общий TransactionManager или LockManager а PostgresTransactionManager и RedisLockManager. Это будет правдой.

                        Моя принципиальная позиция в том, что интерфейс типа TransactionManager и LockManager в core модуле не выполняет поставленных задач по изолированию решения. Эти абстракции нестабильны.

                        Если вы эти интерфейсы оставляете и используете в модуле core, то с точки зрения DI(P) нет разницы куда вы его спрячете внутри модуля core.

                        Иными словами, важно, что граница консистентности явно задана в коде (я исхожу из того, что она не всегда совпадает с границей метода, потому что иначе можно было бы уже перейти на Command/Handler pattern и унести консистентность в промежуточный обработчик). Все остальное — детали реализации. Если я когда-нибудь обнаружу себя в позиции, когда мне надо поддерживать такую сложную и меняющуюся инфраструктуру, как вы описываете ("а вот тут постгрес, а вот тут — редис, а вот тут еще что-то"), я… спрячу это в ConsistencyManagerImpl:

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

                        ---

                        Смотрите на самом деле я с вами согласен относительно многих недостатков решения с наблюдателем. Очевидно, что явное использование transactionManager и отсутствие необходимости упорядочивать наблюдателей это преимущество.

                        И в этом плане во второй части, у меня планируется раздел, который решает эту проблему. Он избавляет в большинстве случаев от использования наблюдателей совсем, при этом, что очень важно (по крайней мере для меня), не добавляет в модуль core интерфейсы типа TransactionManager и LockManager, UoM, ConsistancyManager и подобные им.

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

                        Я тезисно накидаю суть:

                        1. Использование наблюдателей обладает рядом недостатков, (тут мы их подробно обсудили).

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

                        3. Тогда можно добавить дополнительный модуль application, для которого разрешить использование инфраструктурных интерфейсов, типа TransactionManager или LockManager и т.д.

                        4. Слой application - промежуточный слой. Которому будет позволительно поддаваться большему влиянию инфраструктуры. Но при этом он закрыт от инфраструктуры стандартным решением через инверсию зависимостей.

                        5. Зависимости между модулями следующие:

                          • postgres/redis зависит от application и core

                          • application зависит от core

                          • core ни от кого не зависит

                        6. В слое application есть внешний класс сервиса, пусть будет OrderAppService, который выполняет функцию декоратора над OrderService.

                        class OrderAppService {
                          private OrderService coreService;
                          private TransactionManager transactionManager;
                          private LockManager lockManager;
                          
                          public void create(CreateOrderRequest request) {
                            //transactionManager and lockManager usage
                            coreService.create(request);
                            //transactionManager and lockManager usage	
                          }
                        }

                        Все приведенные мною вопросы, а "что если?" решаются на уровне модуля application без изменений модуля core и без добавления в модуль core transactionManager интерфейса и ему подобных.

                        В модуле core не будет использованы никакие наблюдатели, т.к. они после использования модуля application ненужны.

                        Также отмечу что у решения с декоратором (которым выступает OrderAppService) есть один недостаток. Он может добавлять логику только перед и после основного метода.

                        Тогда там будет предложена общая стратегия следующего плана:

                        • Не добавлять transactionManager, lockManager и прочие интерфейсы в модуль core

                        • Добавить их в модуль application и решать все возможные подобные задачи в нем. Скорее всего это будет 99% решений.

                        • Если возникнет потребность внедрить, код по работе с инфраструктурой где-то посередине основной логики в модуле core, то только в этом случае переключаться на решение с наблюдателями.

                        ---

                        Отмечу, что вы говорили, например, про отдельный модуль consitency-manager и вам показалось это странным решением. Что это модуль ради модуля. Но application модуль будет брать на себя больше ответственности. Об этом также расскажу в следующей части.


                      1. lair
                        11.10.2021 15:31

                        Моя принципиальная позиция в том, что интерфейс типа TransactionManager и LockManager в core модуле не выполняет поставленных задач по изолированию решения. Эти абстракции нестабильны.

                        Это неправда. Во-первых, они стабильны по отношению к бизнес-логике.


                        А во-вторых, этих интерфейсов больше нет, откуда вы их вообще взяли?


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

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


                        Смотрите на самом деле я с вами согласен относительно многих недостатков решения с наблюдателем. Очевидно, что явное использование transactionManager и отсутствие необходимости упорядочивать наблюдателей это преимущество.

                        … тогда зачем вы потратили день моего времени на этот спор?


                        не добавляет в модуль core интерфейсы типа TransactionManager и LockManager, UoM, ConsistancyManager и подобные им.

                        Возвращаемся к тому, что ваша бизнес-логика никак не декларирует свои требования к консистентности. Вас это, видимо, устраивает.


                        А меня — нет.


                      1. aa0ndrey Автор
                        11.10.2021 15:39

                        Это неправда. Во-первых, они стабильны по отношению к бизнес-логике.

                        Они должны быть стабильны по отношению к инфраструктуре.

                        Суть инверсии зависимостей заизолироваться стабильными абстракциями от инфраструктуры.

                        Уточню формулировку, transactionManager и lockManager - абстракции, которые нестабильны по отношению к инфраструктуре, что противоречит принципам DI(P)

                        А во-вторых, этих интерфейсов больше нет, откуда вы их вообще взяли?

                        К сожалению, вы не приводите достаточного количества примеров, чтобы понять ваши решения. Почему их нет? Где они есть?

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

                        Приведите пример. Вызов где-то посередине бизнес-логики редкий кейс. То что вы утверждаете необходимость втыкать после каждого строчки кода звучит фантастично.

                        Возвращаемся к тому, что ваша бизнес-логика никак не декларирует свои требования к консистентности. Вас это, видимо, устраивает.

                        Да, конечно, устраивает. Потому что в бизнес-логике я хочу решать другие задачи

                        … тогда зачем вы потратили день моего времени на этот спор?

                        Я отвечал на ваши неверные с моей точки зрения альтернативные решения.

                        Прошу обратить внимание на статью. В статье я подчеркивал, что конкретное решение для transactionManager через наблюдателя не является самым удачным.

                        Также в статье я подмечал, что более удачное решение будет продемонстрировано с модулем application

                        Обозначенные недостатки для наблюдателей я не отрицал. Но альтернатива с явными интерфейсами в модуле core давала больше недостатков (в моем понимании). На что я пытался всюду указать.

                        В статье в 3 разделе я отдельно описываю, почему считаю использование интерфейсов подобных TransactionManager в модуле core недопустимым.

                        Моя принципиальная позиция не в наблюдателях, а в отказе от использования интерфейса TransactionManager в модуле core. Наблюдатель - это одно из решений, которое мне удалось продемонстрировать в 1-ой части.


                      1. lair
                        11.10.2021 16:40

                        Уточню формулировку, transactionManager и lockManager — абстракции, которые нестабильны по отношению к инфраструктуре, что противоречит принципам DI(P)

                        Нет, не противоречит. Вот формулировка dependency injection principle:


                        • High-level modules should not import anything from low-level modules. Both should depend on abstractions (e.g., interfaces).
                        • Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.

                        Здесь нет ничего ни про стабильность, ни про инфраструктуру.


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

                        Вот же пример:


                        public Scope beginScope() {
                          var scope = new Scope();
                          scope.setHandlerState(consistencyHandlers.map(h -> h.begin(scope)));
                          return scope;
                        }

                        Здесь больше нет никаких transaction и lock manager, здесь есть только consistency scope handlers, которые находятся на том же уровне абстракции, что и ваши наблюдатели, только они специфичны для задачи.


                        Вызов где-то посередине бизнес-логики редкий кейс.

                        Но он возможен? Значит, надо на него заложиться. Как это делается в архитектуре с наблюдателями?


                        Потому что в бизнес-логике я хочу решать другие задачи

                        Для меня строгая консистентность данных — это свойство бизнес-логики.


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


                        В статье в 3 разделе я отдельно описываю, почему считаю использование интерфейсов подобных TransactionManager в модуле core недопустимым.

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


                      1. aa0ndrey Автор
                        11.10.2021 17:24

                        Нет, не противоречит. Вот формулировка dependency injection principle:

                        И

                        Здесь нет ничего ни про стабильность, ни про инфраструктуру.

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

                        Чистая архитектура. Роберт Мартин. Издательство Питер, 2019 год. Глава 11 Принцип инверсии зависимостей. Раздел: Стабильные абстракции. ст. 101

                        Здесь больше нет никаких transaction и lock manager, здесь есть только consistency scope handlers, которые находятся на том же уровне абстракции, что и ваши наблюдатели, только они специфичны для задачи.

                        Да но только вы все ещё не показываете конкретных деталей)

                        Иногда необходимо передавать данные из основного кода в инфраструктуру. Иногда из инфраструктуры в основной код. Это очень удобно показывать пример без возвращаемых и принимаемых значений.

                        Как это сделать с помощью наблюдателей я также продемонстрировал в статье. Покажите и вы для scope.

                        Но он возможен? Значит, надо на него заложиться. Как это делается в архитектуре с наблюдателями?

                        Добавлением очередного события. А как это будет делаться в Scope? Предположу, что добавлением, очередного метода.

                        А какое вы тогда например дадите название этому методу для Scope? Вот допустим необходимо что-то сделать, перед проверкой баланса пользователя. В статье неважно что необходимо сделать в инфраструктуре и метод называется beforeCheckUserBalance.

                        Как будет называться метод в этом случае для scope? Важно ли что будет делаться внутри инфраструктуры, чтобы назвать этот метод? Пусть ещё туда необходимо передать userId.

                        Для меня строгая консистентность данных — это свойство бизнес-логики.

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

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

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

                        Рад что вы спросили. Использование репозиториев и клиентов - это стабильные абстракции. Они не зависят от СУБД. Абстракция с методом findById для любой СУБД будет устойчива к изменениям инфраструктуры. Даже хоть мы откажемся от СУБД и будем получать данные от другого сервиса.


                      1. lair
                        11.10.2021 17:40

                        Вам остаётся либо мне поверить, либо прочитать книгу, которая является первоисточником.

                        Чистая архитектура. Роберт Мартин. Издательство Питер, 2019 год. Глава 11 Принцип инверсии зависимостей. Раздел: Стабильные абстракции. ст. 101

                        Только это не первоисточник. Первоисточник — намного старше (в википедии есть ссылка).


                        Впрочем как раз к Clean Architecture у меня есть доступ, так что нет необходимости верить вам на слово, можно просто посмотреть. Вот этот раздел:


                        STABLE ABSTRACTIONS
                        Every change to an abstract interface corresponds to a change to its concrete implementations. [...]
                        Indeed, good software designers and architects work hard to reduce the volatility of interfaces. They try to find ways to add functionality to implementations without making changes to the interfaces. This is Software Design 101.
                        The implication, then, is that stable software architectures are those that avoid depending on volatile concretions, and that favor the use of stable abstract interfaces. This implication boils down to a set of very specific coding practices:
                        Don’t refer to volatile concrete classes. [...]
                        Don’t derive from volatile concrete classes. [...]
                        Don’t override concrete functions. [...]
                        Never mention the name of anything concrete and volatile. [...]

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


                        На самом деле, намного более важный абзац — он выше


                        It is the volatile concrete elements of our system that we want to avoid depending on. Those are the modules that we are actively developing, and that are undergoing frequent change.

                        Так вот, это как раз иллюстрация моего тезиса про бизнес-логику. Инфраструктура меняется редко. Бизнес-логика меняется часто. Ergo, этот абзац рекомендует нам сначала избегать зависимости от бизнес-логики.


                        Иногда необходимо передавать данные из основного кода в инфраструктуру. Иногда из инфраструктуры в основной код. Это очень удобно показывать пример без возвращаемых и принимаемых значений.

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


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


                        Добавлением очередного события.

                        Вот вся стабильность и уплыла.


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

                        Не может быть "неважно что". Scope отвечает исключительно за консистентность данных. То, какую связанную с этим операцию вы хотите добавить, и определит, как она будет называться.


                        И эта единственность ответственности и делает этот интерфейс стабильным. У него мало причин для изменений. У событий — больше.


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

                        Потому что вы игнорируете порядок операций.


                        Рад что вы спросили. Использование репозиториев и клиентов — это стабильные абстракции. Они не зависят от СУБД.

                        Вот и UoW — стабильная абстракция, равно как и ConsistencyManager.


                        Абстракция с методом findById для любой СУБД будет устойчива к изменениям инфраструктуры. Даже хоть мы откажемся от СУБД и будем получать данные от другого сервиса.

                        Ха! Вам, я так понимаю, никогда не приходилось переписывать операцию получения данных с синхронной версии (у вас именно такие) на асинхронные? А это именно то, что у меня однажды произошло, когда мы заменили репозиторий-поверх-БД на репозиторий-поверх-сервиса.


                        Стабильная абстракция my ass.


                      1. aa0ndrey Автор
                        11.10.2021 19:14

                        0)

                        Ха! Вам, я так понимаю, никогда не приходилось переписывать операцию получения данных с синхронной версии (у вас именно такие) на асинхронные? А это именно то, что у меня однажды произошло, когда мы заменили репозиторий-поверх-БД на репозиторий-поверх-сервиса.

                        Стабильная абстракция my ass.

                        Приходилось. Об этом я расскажу во второй части. Посмотрите в конце статьи анонс. Там есть идея о разбиении процесса на этапы.

                        1) Если у вас есть доступ к этому разделу (стабильная абстракция), странно что вы ссылались на вики и сообщали мне о том, что в DIP ничего не упоминается про стабильность абстракций.

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

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

                        ---

                        2) Обратите внимание на ваше решение. Ваш Scope с его Handler все больше похож на список наблюдателей. У вас также кстати появился список. Для которого также нужно решать задачу очерёдности. Также появились общие отвязанные от конкретики интерфейсы handler, которые неявно (в той же степени что и observer) что-то делают.

                        Ваше решение все сильнее приближается к списку наблюдателей. И начинает иметь те же недостатки.

                        Но вот чтобы его совсем не приблизить, вы просто говорите, что ваше решение не может быть вызвано в произвольном месте.

                        Также ваше решение не позволяет передавать данные по всем направлениям.

                        Если я приведу ещё примеры, а "что если"? Вы ещё сильнее измените решение. Возможно оно в конечном итоге превратиться в список наблюдателей, только это будет список handler-ов.

                        3) Я предлагаю завершить нашу дискуссию. У нас с вами разные мнения на этот счет. Это хорошо в плане развития темы. Но, к сожалению, я как и, скорее всего, вы потратили достаточно много времени


                      1. lair
                        11.10.2021 19:22

                        Если у вас есть доступ к этому разделу (стабильная абстракция), странно что вы ссылались на вики и сообщали мне о том, что в DIP ничего не упоминается про стабильность абстракций.

                        Ничего странного. В DIP — в оригинальном тексте — ничего не говорится о стабильности абстракций. Про стабильность абстракции говорит раздел намного более поздней книги, и это совсем другое.


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

                        Я его игнорирую, потому что он ничего не меняет. Он говорит "стараются ограничить и пытаются найти пути". Мы с вами оба стараемся ограничить и найти пути, и этот абзац никак не говорит, чей путь больше соответствует принципам.


                        Также появились общие отвязанные от конкретики интерфейсы handler

                        Ровно здесь и отличие: они не отвязаны от конкретики, они отвечают только за задачи консистентности.


                        Ваше решение все сильнее приближается к списку наблюдателей.

                        Для потребителя — для кода в OrderService — нет. Там как был явный вызов одной операции, так и остался.


                        Но вот чтобы его совсем не приблизить, вы просто говорите, что ваше решение не может быть вызвано в произвольном месте.

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


                        Также ваше решение не позволяет передавать данные по всем направлениям.

                        … потому что передача данных из произвольной реализации обратно вызывающему сломает инкапсуляцию. А передавать данные самому себе хэндлеры как раз могут, для этого там scope и state.


                      1. lair
                        11.10.2021 21:16

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

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

                        Эмм, вот же этот абзац в моей цитате выше:


                        Indeed, good software designers and architects work hard to reduce the volatility of interfaces. They try to find ways to add functionality to implementations without making changes to the interfaces. This is Software Design 101.

                        (кстати, хорошо видно, что перевод… не очень)


    1. aa0ndrey Автор
      10.10.2021 22:58

      Наша дискуссия достигла огромных размеров. За ней уже тяжело следить по уровням ответов. Если я пропустил или не ответил на какие-то важные вопросы, прошу их продублировать в этом новом треде, если хотите продолжить)

      Это очень здорово, что получилось обсудить данный вопрос так подробно. Думаю, что остальным участникам будет достаточно интересно узнать разные мнения по этому поводу.


      1. amakhrov
        11.10.2021 08:53

        Спасибо, было любыпотно следить за полемикой. Согласен с @lair, что наблюдатель тут применен не по назначению. В принципе, все аргументы выше уже были приведены. Зависимость от четкого порядка наблюдателей, неявное выражение намерения.


        1. aa0ndrey Автор
          11.10.2021 12:25

          Да вы правы, эти недостатки есть в наблюдателе. Но он дает более стабильную абстракцию. К сожалению, второй поток мне будет тяжело поддерживать, поэтому сошлюсь на ответ


  1. Arashi5
    11.10.2021 08:38
    +4

    aa0ndrey, lair
    Коллеги, не останавливайтесь, ваша дискуссия - есть показатель, что хабр еще торт)
    PS.
    С нетерпением жду, развития событий.
    PPS.
    Не сарказм.


    1. Georrg
      16.10.2021 01:59

      когда они только работать успевают))


    1. aa0ndrey Автор
      18.10.2021 00:10
      -1

      Теперь я понимаю, почему многие авторы не пытаются отвечать на вопросы. Проблема тут не в размерах дискуссии, а в игнорировании правил этикета хабра. Отвечать на подобные посты 1, 2, 3 крайне неприятно


  1. Throwable
    16.10.2021 13:45
    +1

    Правильный TransactionManager обязательно включает метод rollback().

    Вообще говоря, код из (2) нужно было бы написать в конструкции try-catch-finally, также при этом используя rollback в случае исключения, но напомню, что примеры намерено упрощены. А еще лучше было бы использовать аннотацию @Transactional из какого-либо фреймворка

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

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

    С этим согласен, но предлагаемое решение -- реально жесть и мусор. Транзакции -- контекстно зависимая вещь. Чтобы скрыть детали реализации самым лучшим способом будет поставить AOP interceptor вручную или при помощи аннотации фреймворка. Если нет фреймворка -- то в тыщу раз лучше оставить TM как явную зависимость в бизнес коде.

    private final List<CreateOrderObserver> observers;

    Это очень хреновое решение, хуже может только использование EventBus. Обзерверы -- это антипаттерн, служащий в основном, чтобы слабо связать компоненты и передать событие от более низкого компонента в иерархии к более высокому. Он вводит в код неявные трудноразрешимые динамические зависимости, о которых ничего не известно из контекста кода. Соответственно трудно читать, трудно поддерживать, трудно отлаживать и вообще все плохо. Конкретно здесь не нужна слабая связанность нужно объявить въявную зависимость кода от TM. Тем более, зачем использовать списки, если в контексте есть только единственный хендлер. Делать излишнюю абстракцию для удовлетворения конкретного кейса себе и другим дороже.

    observers.forEach(observer -> observer.onStart(context));

    Как вседа оптимистичное программирование для идеального мира, где по небу летают розовые пони, а код никогда не ломается. Если свалится первый обработчик -- другие что, не получат сообщения? А должны? Если свалится дальнейший код в теле метода, обработчики не получат end, а транзакция никогда не закроется? А если первый обработчик получил onStart(), а второй свалился, нужно ли будет первому послать onEnd()? И еще куча пессимистичных нюансов, которые превратят данный паттерн в настоящий геморрой. Так что на свалку данный велосипед.


    1. aa0ndrey Автор
      17.10.2021 23:08

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

      Если бы была цель написать про transactionManager, то это было бы безусловно верно. Статья не про TransactionManager. У вас есть сомнения, что предложенные методы могут и добавить rollback и проверку наличия транзакции?

      С этим согласен, но предлагаемое решение -- реально жесть и мусор. Транзакции -- контекстно зависимая вещь. Чтобы скрыть детали реализации самым лучшим способом будет поставить AOP interceptor вручную или при помощи аннотации фреймворка. Если нет фреймворка -- то в тыщу раз лучше оставить TM как явную зависимость в бизнес коде.

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

      Об этом тоже указал в статье. Для transaction manager лучшее решение будет продемонстрировано во второй части с использованием модуля application. Тут нет цели продемонстрировать фреймворк или как пользоваться AOP.

      Замечу, что AOP это по своей сути, по крайней мере если мы говорим про аналог реализации @Transactional , реализация паттерна декоратор. То что это решение (с декаратором) предпочтительнее я указал как в статье, так и в коментарии тут

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

      Это очень хреновое решение, хуже может только использование EventBus. Обзерверы -- это антипаттерн, служащий в основном, чтобы слабо связать компоненты и передать событие от более низкого компонента в иерархии к более высокому. Он вводит в код неявные трудноразрешимые динамические зависимости, о которых ничего не известно из контекста кода. Соответственно трудно читать, трудно поддерживать, трудно отлаживать и вообще все плохо. Конкретно здесь не нужна слабая связанность нужно объявить въявную зависимость кода от TM. Тем более, зачем использовать списки, если в контексте есть только единственный хендлер. Делать излишнюю абстракцию для удовлетворения конкретного кейса себе и другим дороже.

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

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

      Но вы драматизируете. Весь UI строится на наблюдателях, также большинство фреймворков в качестве механизма расширения используют наблюдателей. Тот же Spring с его PostConstruct и PostDestroy и другие события жизненного цикла бина. Принципиального отличия от onStart и onEnd в этом плане нет. Плагины расширения также строятся на наблюдателях. Есть событийно ориентированные архитектуры. Заявлять, что наблюдатель является антипаттерном очень смело с вашей стороны.

      Как вседа оптимистичное программирование для идеального мира, где по небу летают розовые пони, а код никогда не ломается. Если свалится первый обработчик -- другие что, не получат сообщения? А должны? Если свалится дальнейший код в теле метода, обработчики не получат end, а транзакция никогда не закроется? А если первый обработчик получил onStart(), а второй свалился, нужно ли будет первому послать onEnd()? И еще куча пессимистичных нюансов, которые превратят данный паттерн в настоящий геморрой. Так что на свалку данный велосипед.

      Нет никакого оптимистичного программирования. Если свалится первый обработчик, то либо в обработчике должна быть конструкцию try-catch, если мы подразумеваем, что что-то необходимо сделать в случае проблемы с обработкой, либо если обработчик упадет, и есть какая-то общая стратегия по обработки exception-ов, то exception должен пролететь как есть до места, где его словят. Если мы говорим про Spring, то это может быть ExceptionHandler.

      Если вас интересуют возможность try - catch - finally, чтобы в одном месте открыть транзакцию, а в другом гарантировано закрыть, то посмотрите на сообщение тут

      Так что на свалку данный велосипед.

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

      -------------

      Надеюсь данный пример решить все возникщие у вас вопросы.

      Представим код без использования наблюдателей, с TransactionManager у которого есть 4 метода

      interface TransactionManager {
        void begin();
        void commit();
        void rollback();
        boolean hasActive();
      }
      class OrderService {
        public void create(CreateOrderRequest request) {
          try {
            if (!transactionManager.hasActive()) {
              transactionManager.begin();
            }
      
            //some domain code
      
            transactionManager.commit();
          } catch (Exception e) {
            if (transactionManager.hasActive()) {
              transactionManager.rollback();
            }
          }
        }
      }

      Теперь рассмотрим тот же пример, но с наблюдателями

      class OrderService {
        public void create(CreateOrderRequest request) {
          try {
            observers.forEach(observer -> observer.onStart());
            
            //some domain code
            
            observers.forEach(observer -> observer.onEnd());
          } catch (Exception e) {
            observers.forEach(observer -> observer.onCreationFailed(e);
          }
        }
      }
                              
      class CreateOrderObserverImpl {
        public void onStart() {
          if (!transactionManager.hasActive()) {
            transactionManager.begin();
          }
        }
        
        public void onEnd() {
          transactionManager.commit();
        }
        
        public void onCreationFailed(Exception e) {
          if (transactionManager.hasActive()) {
            transactionManager.rollback();
          }
        }
      }


      1. lair
        18.10.2021 09:42

        Весь UI строится на наблюдателях, также большинство фреймворков в качестве механизма расширения используют наблюдателей [...] Плагины расширения также строятся на наблюдателях.

        Это, наверное, про Java? Потому что в .net, с которым я привык работать, все совсем не так.


      1. Throwable
        18.10.2021 12:20
        +1

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

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

        То что это антипаттерн это только по вашему мнению.

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

        1. События синронны или асинхронны?

        2. Гарантируется ли детерминированный порядок выполнения (и какой)?

        3. Гарантируется ли доставка сообщения, если removeListener() или addListener() вызван внутри обработчика? В вашем примере поведение недетерминировано.

        4. Если один из хендлеров кинул исключение, гарантируется ли доставка сообщения другим хендлерам?

        5. Уже упомянутый: гарантируется ли вызов onEnd(), если обработчик обработал onStart(), а другой выкинул исключение?

        6. Связанная проблема корректного shutdown-а: событие может послаться уже остановленному компоненту (проблема неразрешима, если есть циклические зависимости, которые может давать паттерн observer).

        7. А если учесть, что обработчики могут провоцировать повторные события от того же компонента, то здесь образуется еще целое поле недетерминированных поведенческих сценариев:

        textField.onValueChange(e -> {
          if (e.getValue().endsWith(", "))
            textField.setValue(e.getValue() + "fck, ");
        });

        Представим код без использования наблюдателей, с TransactionManager у которого есть 4 метода

        public void create(CreateOrderRequest request) {
          // практически реальный код -- вся магия внутри TM, принцип DRY соблюден
          // бизнес код защищен от некорректного использования и забывчивости/неумения программиста
          transactionManager.transactional(() -> {
            // some domain code
          });
        }

        Теперь рассмотрим тот же пример, но с наблюдателями

        Предположим, что мы хотим навесить более одного наблюдателя -- а кто нам мешает, раз уж дизайн позволяет? Например я хочу еще залогировать сие событие в audit таблицу в базе данных. Тогда сразу же встает проблема последовательности инициализации (которая есть неявная): если контекст фреймворком был собран так, что первый обработчик -- это TM, тогда все нормально -- второй запишет в audit таблицу уже в транзакции. Если же нет -- первым вызовется audit и упадет с ошибкой transaction required.

        Ладно, вы создаете отдельный AuditService, где делаете свои обзерверы и добавляете тот же обзервер с транзакцией. А теперь мне нужно отослать данные только что созданного заказа (и еще клиента) удаленному rest-логгеру. Я вешаю обработчик на onEnd() и опять проблема с последовательностью вызовов: может случиться, что транзакция уже как бы закрыта, а мне нужно еще читать данные клиента. Или в другой транзакции будем читать?

        Теперь позвольте мне сделать review вашего кода:

        class OrderService {
          public void create(CreateOrderRequest request) {
            // ремарка: это нужно будет копипейстить в любом компоненте, где требуется транзакция
            try {
              // если список обзерверов может меняетья динамически,
              // то нужно делать дефенсивную копию
              // new ArrayList<>(observers).forEach(...)
              observers.forEach(observer -> observer.onStart());
              
              //some domain code
              
              // для обеспечения детерминированности вызовы onEnd()
              // должны осуществляться в обратном порядке (LIFO)
              // тот, кто первым открыл транзакцию, должен закрыть ее последней
              observers.forEach(observer -> observer.onEnd());
            } catch (Exception e) {
              // абсолютно нелогично вызывать onCreationFailed() для обработчиков,
              // для которых не был вызван onStart() - они вообще ничего не должны знать о событии
              // более того, для некоторых уже успел вызваться onEnd(), зачем им посылать onCreationFailed()?
              // они уже освободили ресурсы и забыли про операцию
              // Забыл добавить: onCreationFailed() тоже может выкинуть исключение,
              // и в этом случае остальные обработчики не получат сообщение, что приведет
              // к утечке ресурсов. Здесь нужно вызывать хендлер в try - catch и продолжать в случае ошибки.
              // А после цепочки вызовов кинуть исключение с той ошибкой.
              observers.forEach(observer -> observer.onCreationFailed(e));
            }
          }
        }
        
        // Ваш обработчик нереентерабелен (не позволяет корректно работать внутри уже созданной транзакции)
        // Как надо было:
        class CreateOrderObserverImpl {
          // Нет гарантии, что это prototype, поэтому использует ThreadLocal
          ThreadLocal<Boolean> isManagedByCurrent = new ThreadLocal<>();
          
          public void onStart() {
            if (!transactionManager.hasActive()) {
              transactionManager.begin();
              isManagedByCurrent.set(true);
            }
          }
          
          public void onEnd() {
            // если транзакция создалась выше, мы ее и не должны коммитить
            if (isManagedByCurrent.get()) {
              try {
                transactionManager.commit();
              } finally {
                isManagedByCurrent.clear();
              }
            }
          }
          
          public void onCreationFailed(Exception e) {
            if (isManagedByCurrent.get()) {
              try {
                transactionManager.rollback();
              } finally {
                isManagedByCurrent.clear();
              }
            }
          }
        }

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


        1. aa0ndrey Автор
          18.10.2021 12:53

          Комментарий удален, т.к. отправлен неполным, создаю полный


          1. aa0ndrey Автор
            18.10.2021 13:56

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

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

            События синронны или асинхронны?

            Синхроны или асинхроны в каком смысле? Есть реактивные фреймворки типа Reactor они делают в одном смысле асихронность без потери контекста. Есть очереди сообщений, для которых обработки подразумевает потерю контекста.
            Предположу, что вы имели ввиду асинхронность подобную Reactor (без потери контекста). Тогда если это важно, то можно всех наблюдателей делать асинхронными, с последовательным вызовом после окончания очередного вызова.

            Гарантируется ли детерминированный порядок выполнения (и какой)?

            Гарантируется, всегда в порядке очередности в которой произошла подписка. Если это влияет на логику выполнения, то порядком можно управлять. Если мы говорим про Spring то для этого достаточно добавить аннотация `@Order` с цифрой.

            Гарантируется ли доставка сообщения, если removeListener() или addListener() вызван внутри обработчика? В вашем примере поведение недетерминировано.

            Верно, потому что в примерах нет необходимости это детерминировать. И в большинстве случаев их нет необходимости детерминировать.

            Если это важно такие гарантии можно как дать так и нет. Но если мы говорим конкретно про решаемые задачи в статье, то таких проблем даже не будет возникать. Все сервисы singelton, привязываемые обработчики также singleton, привязка только в момент инициализации бинов.

            Отсюда следствие: не будет необходимости отвязывать или привязывать наблюдателей внутри обработки. Также нет необходимости в removeListener.
            Возможно, такой кейс может возникнуть, но он крайне редкий.

            Если один из хендлеров кинул исключение, гарантируется ли доставка сообщения другим хендлерам?

            Если кидается исключение из обработчика, то для остальных обработчиков не доходит сообщение.

            Если важно, чтобы после обработчика другие обработчики что-то выполнили, то внутри обработчика должен быть try-catch. То есть в этом смысле обработчик не прокинет сообщение выше.

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

            Уже упомянутый: гарантируется ли вызов onEnd(), если обработчик обработал onStart(), а другой выкинул исключение?

            Нет, следует из предыдущего ответа

            Связанная проблема корректного shutdown-а: событие может послаться уже остановленному компоненту (проблема неразрешима, если есть циклические зависимости, которые может давать паттерн observer).

            Да, циклические зависимости в шаблоне наблюдатель это проблема. Но они в большей степени справедливы для UI, где возможны циклические потоки данных. В статье описан подход в большей степени для бизнес-логики. В силу специфики области применения, там поток данных однонаправленный. От core в базу, от core в другой сервис, от core в очередь. Обозначенная проблема с цилкическими зависимостями очень редкий кейс.
            P.s. Кейс от core в core через наблюдателей исключается, т.к. наблюдатели не предлагается использовать для вызовов в одном и том же модуле.

            А если учесть, что обработчики могут провоцировать повторные события от того же компонента, то здесь образуется еще целое поле недетерминированных поведенческих сценариев:

            Если говорить в принципе про наблюдателей, то проблема есть. Но в контексте области применения её нет. Ответил выше.

            ------

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

            ------

            Относительно вашего примера с transactionManager

            public void create(CreateOrderRequest request) {
              // практически реальный код -- вся магия внутри TM, принцип DRY соблюден
              // бизнес код защищен от некорректного использования и забывчивости/неумения программиста
              transactionManager.transactional(() -> {
                // some domain code
              });
            }

            На самом деле вы привели ещё один пример с декоратором. Ранее вы говорили про использование AOP - тоже пример с декоратором. Предложенный мною ApplicationService тоже пример с декоратором. Противоречия с тем, что декоратор для TM это в большинстве случаев самое удачно решение, с этим у меня нет никаких противоречий. В статье об этом я также написал. Но проблема в том, что вы оставили его в core модуле.

            Предположим, что мы хотим навесить более одного наблюдателя -- а кто нам мешает, раз уж дизайн позволяет? Например я хочу еще залогировать сие событие в audit таблицу в базе данных. Тогда сразу же встает проблема последовательности инициализации (которая есть неявная): если контекст фреймворком был собран так, что первый обработчик -- это TM, тогда все нормально -- второй запишет в audit таблицу уже в транзакции. Если же нет -- первым вызовется audit и упадет с ошибкой transaction required.

            Ладно, вы создаете отдельный AuditService, где делаете свои обзерверы и добавляете тот же обзервер с транзакцией. А теперь мне нужно отослать данные только что созданного заказа (и еще клиента) удаленному rest-логгеру. Я вешаю обработчик на onEnd() и опять проблема с последовательностью вызовов: может случиться, что транзакция уже как бы закрыта, а мне нужно еще читать данные клиента. Или в другой транзакции будем читать?

            Проблема с очередностью как уже несколько раз писал, решается одной аннотацией @Order с цифрой очередности. Если вы предлагали решение с AOP то также должны знать, что это проблема решается там аналогично.

            Порядок обработки детерминирован и его можно задать без особой сложности.

            Теперь позвольте мне сделать review вашего кода:

            Позвольте мне сделать ревью вашего ревью. Ответы на ваши комментарии имеют дополнительный отступ и указываются после ваших комментариев.

            class OrderService {
              public void create(CreateOrderRequest request) {
                // ремарка: это нужно будет копипейстить в любом компоненте, где требуется транзакция
                try {
                  // если список обзерверов может меняетья динамически,
                  // то нужно делать дефенсивную копию
                  // new ArrayList<>(observers).forEach(...)
            
                  //  Вы решаете несуществующую проблему, т.к. нет задачи
                  //  по динамическому добавлению и удалению
                  //  Более того для этого есть CopyOnWriteArrayList,
                  //  поэтому приведенный код полностью валиден.
                  observers.forEach(observer -> observer.onStart());
                  
                  //some domain code
                  
                  // для обеспечения детерминированности вызовы onEnd()
                  // должны осуществляться в обратном порядке (LIFO)
                  // тот, кто первым открыл транзакцию, должен закрыть ее последней
                  
                  //  Наблюдатели не должны знать и решать проблему LIFO в явном виде
                  //  Для этого используется механизм очередности в котором добавляются
                  //  наблюдатели. Смотрите ответы выше про аннотацию @Order
                  //  То что транзакции должны закрываться в обратном порядке, это деталь
                  //  реализации, её нужно решать не здесь.
                  observers.forEach(observer -> observer.onEnd());
                } catch (Exception e) {
                  // абсолютно нелогично вызывать onCreationFailed() для обработчиков,
                  // для которых не был вызван onStart() - они вообще ничего не должны знать о событии
                  // более того, для некоторых уже успел вызваться onEnd(), зачем им посылать onCreationFailed()?
                  // они уже освободили ресурсы и забыли про операцию
                  // Забыл добавить: onCreationFailed() тоже может выкинуть исключение,
                  // и в этом случае остальные обработчики не получат сообщение, что приведет
                  // к утечке ресурсов. Здесь нужно вызывать хендлер в try - catch и продолжать в случае ошибки.
                  // А после цепочки вызовов кинуть исключение с той ошибкой.
                  
                  //  Абсолютно логично, напишите код без наблюдателей, с конкретной реализацией
                  //  на примере интерфейса transactionManager, который привел ранее.
                  //  Про обработку ошибок написал выше. Смотрите, я свой же пример переписал
                  //  с помошью наблюдателей, полностью сохранив все гаранти которые давал мой пример без
                  //  наблюдателей. Приведите более сложный пример, и он аналогичным образом будет
                  //  решен через наблюдателей.
                  //  А про то, что вы забыли добавить. Если все обработчики должны выполнить свой код,
                  //  то значит должен быть внутри try-catch. Вообще если вы знакомы с AOP то должны понимать,
                  //  что обернув в конструкцию try catch и поставив обработку в начале, в конце и в случае исключения
                  //  это полностью эквивалентно аспекту Around, и все что можно сделать с аспектом
                  //  Around справедливо и для приведенной конструкции.
                  observers.forEach(observer -> observer.onCreationFailed(e));
                }
              }
            }
            
            // Ваш обработчик нереентерабелен (не позволяет корректно работать внутри уже созданной транзакции)
            // Как надо было:
            
            //  Не было цели сделать точную копию TM. Да вы правы в конкретной реализации
            //  у меня есть ошибка для TM, но нет ошибки в используемом приеме с наблюдателями. 
            //  То есть на суть использования приемов приведенных в статье это никак не влияет.
            class CreateOrderObserverImpl {
              // Нет гарантии, что это prototype, поэтому использует ThreadLocal
              ThreadLocal<Boolean> isManagedByCurrent = new ThreadLocal<>();
              
              public void onStart() {
                if (!transactionManager.hasActive()) {
                  transactionManager.begin();
                  isManagedByCurrent.set(true);
                }
              }
              
              public void onEnd() {
                // если транзакция создалась выше, мы ее и не должны коммитить
                if (isManagedByCurrent.get()) {
                  try {
                    transactionManager.commit();
                  } finally {
                    isManagedByCurrent.clear();
                  }
                }
              }
              
              public void onCreationFailed(Exception e) {
                if (isManagedByCurrent.get()) {
                  try {
                    transactionManager.rollback();
                  } finally {
                    isManagedByCurrent.clear();
                  }
                }
              }
            }

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

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


            1. lair
              18.10.2021 14:20

              Если важно, чтобы после обработчика другие обработчики что-то выполнили, то внутри обработчика должен быть try-catch. То есть в этом смысле обработчик не прокинет сообщение выше.

              Есть, понимаете ли, проблема. Каждый из обработчиков ничего не должен знать про другие обработчики. Т.е. падает у вас обработчик А, а обязательно выполнить надо обработчик Б. А обработчик Ц выполнять не надо. Если добавление обработчика Б требует изменения обработчика А, с архитектурой что-то пошло не так.


              1. aa0ndrey Автор
                18.10.2021 14:44

                Есть, понимаете ли, проблема. Каждый из обработчиков ничего не должен знать про другие обработчики. Т.е. падает у вас обработчик А, а обязательно выполнить надо обработчик Б. А обработчик Ц выполнять не надо. Если добавление обработчика Б требует изменения обработчика А, с архитектурой что-то пошло не так.

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

                Я понимаю о чем вы говорите, но как только вы скрываете A, B и С, вы делаете неявным управление. Только если соберётесь писать код, прошу не использовать methodA, methodB и т.д. Тут крайне важно название, потому что наблюдатель его меняет. Если под methodA кроется beginTransaction или ещё что-то подобное, то это неправильный пример.


                1. lair
                  18.10.2021 14:49

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

                  Пример кода с явным consistency manager есть выше в комментариях.


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


                  Я понимаю о чем вы говорите, но как только вы скрываете A, B и С, вы делаете неявным управление.

                  Именно поэтому я для начала откажусь от множественности (один менеджер, а не много наблюдателей), и в подавляющем большинстве случаев этого будет достаточно для решения моей проблемы. Явный менеджер — явное управление.


                  1. aa0ndrey Автор
                    18.10.2021 14:52

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


                    1. lair
                      18.10.2021 14:56

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


                      1. aa0ndrey Автор
                        18.10.2021 15:11

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

                        Хочу также подчеркнуть, что у меня нет цели предложить все строить на наблюдателях. В целевом решении наблюдатели редкий кейс.

                        Например, предлагаемые решения @Throwableчерез AOP, использование из фреймворка аннотации Transactional, лямбду в transactional - это все разные формы декоратора.

                        Ваши предположения о том, что в основном необходимо выполнять действия в начале и конце метода, чтобы использовать UoM, тоже выравниваются с декоратором. Кстати если не ошибаюсь даже у Эванса в DDD указано, что UoM находится на уровне application layer.

                        С этим нет никаких противоречий. Я сам с этим согласен и сообщаю об этом в статье. Но есть редкие кейсы, которые могут заставить сделать что-то "инфраструктурное" посередине метода. И только в этом случае предлагается использовать наблюдателей.

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

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

                        Если мы исходим из предположения о том, что TM, UoM и им подобные не должны быть в core, иногда чтобы это сделать придётся усложнить, решив тот редкий кейс. Мой опыт подсказывает, что это не превращается в ад с наблюдателями.


                      1. lair
                        18.10.2021 22:21

                        В целевом решении наблюдатели редкий кейс.

                        Но вы посвящаете этому "редкому кейсу" статью, причем еще и продолжая отстаивать неудачный пример.


                        Кстати если не ошибаюсь даже у Эванса в DDD указано, что UoM находится на уровне application layer.

                        Я попробовал поискать, и в DDD нет ни одного упоминания Unit of Work, и в индексе его нет. Вы можете привести цитату?


                        Но есть редкие кейсы, которые могут заставить сделать что-то "инфраструктурное" посередине метода. И только в этом случае предлагается использовать наблюдателей.

                        … и это, в общем-то, противоречит DDD, раз уж вы к нему аппелируете. Потому что посреди бизнес-метода у вас может быть только бизнес-событие, а не абстрактный вызов наблюдателя "потому что нам надо вызвать инфраструктурное".


                      1. aa0ndrey Автор
                        19.10.2021 02:34

                        Но вы посвящаете этому "редкому кейсу" статью, причем еще и продолжая отстаивать неудачный пример.

                        Пример неудачный с вашей точки зрения. Вы же не знаете как я поведу статью. Мне для перехода к описанию application layer нужен был пример, который в него отлично впишется. Это и будет TransactionManager. Также TransactionManager был удобен, чтобы показать все возможные подходы для работы с наблюдателем и контекстом.

                        Я попробовал поискать, и в DDD нет ни одного упоминания Unit of Work, и в индексе его нет. Вы можете привести цитату?

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

                        Запрос в гугле: "эрик эванс предметно-ориентированное проектирование pdf", первая ссылка.

                        Часть 2. Глава 4. Изоляция предметной области.
                        83 страница, рис. 4.1.

                        Там у него пример следующего типа:

                        Класс FundsTransferService из application слоя вызывает что-то из инфраструктурного слоя методом beginTransaction();

                        Затем есть бизнес-логика, внутри доменных сущностей класса Account которые в ходе выполнения вызывают что-то из инфраструктурного слоя методом addToUnitOfWork.

                        Затем FundsTransferService из application слоя вызывает что-то из инфраструктурного слоя с методом commit();

                        ----

                        То есть у Эванса начало и коммит транзакции происходит в application слое. Непонятно правда как по классам распределены эти методы

                        А добавление в БД через методы как-то связанные с UoW. Если UoW без методов begin и commit, то он выглядит как аналог интерфейса Repository.

                        Надо у Миллетта посмотреть у него примеры более подробные.

                        … и это, в общем-то, противоречит DDD, раз уж вы к нему аппелируете. Потому что посреди бизнес-метода у вас может быть только бизнес-событие, а не абстрактный вызов наблюдателя "потому что нам надо вызвать инфраструктурное".

                        К DDD не апеллирую, у меня и модели в статье анемичные, но на распределение ответственности по слоям core/application/infrastructure это вряд ли влияет. Скорее на форму внутри core модуля.


                      1. lair
                        19.10.2021 08:55

                        Вы же не знаете как я поведу статью.

                        Я ее прочитал уже неоднократно.


                        Вы, конечно, имеете в виду свою следующую статью, но мы обсуждаем не ее, а эту.


                        Часть 2. Глава 4. Изоляция предметной области.
                        83 страница, рис. 4.1. [...] Затем есть бизнес-логика, внутри доменных сущностей класса Account которые в ходе выполнения вызывают что-то из инфраструктурного слоя методом addToUnitOfWork.

                        Вот эта картинка.



                        Нас интересуют методы addToUnitOfWork. Тот факт, что они вызываются из бизнес-слоя, говорит нам о двух вещах:


                        1. в бизнес-слое доступна абстракция unit of work (что явно противоречит вашему "UoM не должны быть в Core")
                        2. бизнес-слой отвечает за то, какие объекты (точнее, агрегаты) попадают в unit of work, что противоречит вашему же "UoM находится на уровне application layer".

                        А добавление в БД через методы как-то связанные с UoW. Если UoW без методов begin и commit, то он выглядит как аналог интерфейса Repository.

                        Это не добавление в БД. Это привязка изменений к unit of work, как я уже описывал выше, просто у Эванса она явная. Так что это не repository.


                        Повторюсь еще раз: на картинке из DDD бизнес-слой явно вызывает методы из Unit of Work Manager. Это то, против чего вы протестовали всю дорогу.


                      1. aa0ndrey Автор
                        19.10.2021 12:01

                        Это не добавление в БД. Это привязка изменений к unit of work, как я уже описывал выше, просто у Эванса она явная. Так что это не repository.

                        Повторюсь еще раз: на картинке из DDD бизнес-слой явно вызывает методы из Unit of Work Manager. Это то, против чего вы протестовали всю дорогу.

                        Вы не правы. Пойдём по порядку, я протестовал против явных интерфейсов TransactionManager и LockManager в core модуле. Затем вы предложили UoW и я пытался узнать у вас, как вы намереваетесь его использовать.

                        После вы ушли в сторону ConsistencyManager-а, и я вам заметил, что мне не нравятся любые решения, которые оставят TransactionManager и LockManager в core (тут). И также я против любых решений, которые работают с begin/commit внутри core модуля.

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

                        ----

                        А вот например решение с UoW и ConsistencyManager, которое меня полностью устроит. Также оно выравнено со схемой, которую приводит Эванс. И также оно полностью подходит под определение того, что транзакция управляется вне core модуля и в core модуле нет интерфейсов TransactionManager.

                        //Core module
                        interface UoW {
                          void add(Order order);
                        }
                        
                        //Application module
                        class ConsistencyManager {
                          TransactionManager tm;
                          
                          UoW begin() {
                            var transactionScope = tm.begin();
                            return new UoWImpl(transactionScope);
                          }
                          
                          void commit(UoW uow) {
                            ((UoWImpl) uow).getTransactionScope().commit();
                          }
                        }
                        
                        //Application module
                        class OrderAppService {
                          ConsistencyManager consistencyManager;
                          OrderService coreService;
                          
                          void create(CreateOrderRequest request) {
                            var uow = consistencyManager.begin();
                            
                            coreService.create(request, uow);
                            
                            consistencyManager.commit(uow);
                          }
                        }
                        
                        //Core Module
                        class OrderService {
                          UserRepository userRepository;
                          ProductRepository productRepositor;
                          
                          void create(CreateOrderRequest request, UoW uow) {
                            var user = userRepositor.find();
                            var product = productRepository.find();
                            
                            var order = new Order(user, product);
                            uow.add(order);
                          }
                        }

                        ---

                        Предлагаю подумать почему в DDD используется именно UoW. В DDD основным носителем бизнес-логики являются агрегаты (объекты с данными), которые ещё и восстанавливаются из репозиториев. Т.е. дизайн таких классов плохо сочетается с набором полей из репозиториев. Потому как поля там это данные.

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

                        Но ещё подумайте вот о чем. Раз носителем бизнес операций в DDD являются агрегаты, то там архитектурно невозможно начать транзакцию в ядре. К тому же ещё и агрегат выступает как граница синхронизации, если мы говорим про конкурентность.

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

                        Но для анимичной модели таких ограничений нет. Что вы собственно и продемонстрировали предлагая открывать транзакцию в core модуле.

                        ---

                        Сегодня я ещё и посмотрел как Миллетт определяет ответственность слоев.

                        Часть 2. Глава 8. Application Architecture. Раздел: Dependency Inversion

                        Of course, the state of domain objects needs to be saved to some kind of persistence store. To achieve this without coupling the domain layer to technical code, the application layer defines an interface that enables domain objects to be hydrated and persisted. This interface is written from the perspective of the application layer and in a language and style it understands free from specific frameworks or technical jargon. The infrastructural layers then implement and adapt to these interfaces, thus giving the dependency that the lower layers need without coupling. Transaction management along with cross-cutting concerns such as security and logging are provided in the same manner. Figure 8-2 shows the direction of dependencies and the direction of interfaces that describe the relationship between the application layer and technical layers.


                      1. lair
                        19.10.2021 12:05

                        Поэтому прошу рассматривать мои слова именно в контексте предложенных вами решений.

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


                        Сегодня я ещё и посмотрел как Миллетт определяет ответственность слоев.

                        Я понятия не имею, кто это.


                      1. aa0ndrey Автор
                        19.10.2021 12:21

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

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

                        Я понятия не имею, кто это.

                        Scott Millett автор книги Patterns, Principle, and Practices of Domain-Driven Design. У него книга значительно новее чем у Эванса и рассказывает подробнее. Учитывает такие подходы и механизмы как CQRS, Event Sourcing, микросервисы и т.д.


            1. Throwable
              18.10.2021 15:27

              Синхроны или асинхроны в каком смысле?

              Видимо, с "потерей контекста" как SwingUtilities.invokeLater(...) или setTimeout(() => ...). Большинство реализаций событийной модели никак не декларируют данное поведение, из-за чего приходится сначала исследовать их поведение.

              Гарантируется, всегда в порядке очередности в которой произошла подписка

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

              Если мы говорим про Spring то для этого достаточно добавить аннотация `@Order` с цифрой.

              И параллельно ознакомить всех разработчиков в проекте с глобальной политикой упорядочивания observer-ов, а также открыть репозиторий, где бы каждый сначала резервировал индекс для своих нужд. Это антипаттерн "Magic Number". Проблема в том, что корректность поведения приложения будет зависеть от взаимного расположения всех (в общем случае) транзакционных обзерверов и обзерверов бизнес кода, контроль над которым ложится на плечи разработчиков.

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

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


              1. aa0ndrey Автор
                18.10.2021 15:35

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

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


  1. kemsky
    23.10.2021 01:10
    +1

    Завидное у вас терпение, @aa0ndrey. Идея понятна, и если смотреть шире, то она спокойно заменяется на тот же AOP:

        @WithEvents
        public void create(@Context CreateOrderContext context) {
            var user = context.getUser();
            var product = context.getProduct();
    
            if (user.getBalance() < product.getPrice()) {
                throw new RuntimeException("Недостаточно средств");
            }
    
            var order = new Order(UUID.randomUUID(), user.getId(), product.getId());
            context.setCreatedOrder(order);
        }

    Практически таким же способом у нас на проекте я сделал и блокировки и транзакции и многое другое. Их назначение я не скрывал, но наверное можно было бы сделать что-то более осмысленное, например, вместо @WithEvents сделать @OrderProcessing и для нее прописать правила обработки. Так как атрибутов/аннотаций может быть несколько, то явно прописывается их приоритет, по другому никак. Валидность конфигурации аннотаций проверяется тестом. Моя реализация полностью поддерживает async/await, что дает неограниченные возможности по расширению.

    И сделал я так, потому что видеть код прошитый насквозь try/catch/begin/end/lock/unlock/XXXmanager я не хочу.


    1. aa0ndrey Автор
      24.10.2021 01:44

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

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

      Выше я также старался указать, что считаю важным то, что в core отсутствуют интерфейсы подобные TransactionManager и LockManager и другие интерфейсы с методами begin/commit/lock, которые являются явно инфраструктурными.

      То есть, например, если мы говорим про AOP, то чтобы ваш пример работал необходимо куда-то поместить аспекты, которые будут использовать TransactionManager и LockManager. Скорее всего, чтобы удовлетворить требованию выше, такие аспекты придётся разместить в модуле application. То есть тут я хочу сказать, что в таком случае мы все равно придём к примеру описанному тут , только с помощью аспектов. Но вместо явного декорирующего класса будут использованы аспекты.

      И ещё раз хочу подметить, что все решения с декораторами (включая AOP) обладают одним общим недостатком. Они могут добавлять логику только в начале и конце метода. Т.е. всюду, где мы не можем задекорировать предлагается использовать наблюдателей.

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


      1. kemsky
        24.10.2021 18:22

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

        Если возникает необходимость делать что-то внутри метода, то тут могут быть варианты: вынести в отдельный метод или даже класс; придумать другой способ представления/ абстрагировать задачу; дать явный доступ к TransactionManager и LockManager, если задача встречается редко; сделать специальный сервис с простыми методами используя инверсию зависимостей. Примером может быть Rollback, в 99% случаев можно просто использовать Exception для отслеживания необходимости сделать Rollback, но иногда нужен явный вызов.


        1. aa0ndrey Автор
          25.10.2021 14:24

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

          Выше я имел ввиду не способ реализации АОП, а именно эффект от его использования. Т.е. неважно как реализован АОП, он будет работать как декоратор, то есть добавлять логику перед и после метода.

          дать явный доступ к TransactionManager и LockManager, если задача встречается редко;

          Вот этого очень хочется избежать, т.к. если интерфейсы TransactionManager или LockManager заберутся в core модуль, то потом нужно ещё постоянно контролировать их использование. В этом смысле проще не допустить их использования вообще в core модуле.

          ---

          Предлагаю рассмотреть интересную аналогию

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

          А решениям с наблюдателями, можно сопоставить использование ReentrantLock, которые позволяют в любом месте захватить блокировку и отпустить.