DDD встречает ООП: Как реализовать объектно-ориентированный репозиторий?

12

Типичная реализация хранилища DDD выглядит не очень хорошо, например, save()метод:

package com.example.domain;

public class Product {  /* public attributes for brevity */
    public String name;
    public Double price;
}

public interface ProductRepo {
    void save(Product product);
} 

Инфраструктурная часть:

package com.example.infrastructure;
// imports...

public class JdbcProductRepo implements ProductRepo {
    private JdbcTemplate = ...

    public void save(Product product) {
        JdbcTemplate.update("INSERT INTO product (name, price) VALUES (?, ?)", 
            product.name, product.price);
    }
} 

Такой интерфейс предполагает Productанемическую модель, по крайней мере, с геттерами.

С другой стороны, ООП говорит, что Productобъект должен знать, как сохранить себя.

package com.example.domain;

public class Product {
    private String name;
    private Double price;

    void save() {
        // save the product
        // ???
    }
}

Дело в том, что когда человек Productзнает, как сохранить себя, это означает, что код инфраструктуры не отделен от кода домена.

Может быть, мы можем делегировать сохранение другому объекту:

package com.example.domain;

public class Product {
    private String name;
    private Double price;

    void save(Storage storage) {
        storage
            .with("name", this.name)
            .with("price", this.price)
            .save();
    }
}

public interface Storage {
    Storage with(String name, Object value);
    void save();
}

Инфраструктурная часть:

package com.example.infrastructure;
// imports...

public class JdbcProductRepo implements ProductRepo {        
    public void save(Product product) {
        product.save(new JdbcStorage());
    }
}

class JdbcStorage implements Storage {
    private final JdbcTemplate = ...
    private final Map<String, Object> attrs = new HashMap<>();

    private final String tableName;

    public JdbcStorage(String tableName) {
        this.tableName = tableName;
    }

    public Storage with(String name, Object value) {
        attrs.put(name, value);
    }
    public void save() {
        JdbcTemplate.update("INSERT INTO " + tableName + " (name, price) VALUES (?, ?)", 
            attrs.get("name"), attrs.get("price"));
    }
}

Каков наилучший подход для достижения этого? Возможно ли реализовать объектно-ориентированный репозиторий?

ttulka
источник
6
ООП говорит, что объект Product должен знать, как спасти себя - я не уверен, что это действительно так ... ООП сама по себе на самом деле не диктует этого, это скорее проблема дизайна / шаблона (где DDD / что угодно - вы
-используется
1
Помните, что в контексте ООП речь идет об объектах. Просто объекты, а не постоянство данных. Ваше утверждение указывает, что состояние объекта не должно управляться вне самого себя, с чем я согласен. Репозиторий отвечает за загрузку / сохранение из некоторого уровня персистентности (который находится вне области ООП). Свойства и методы класса должны поддерживать свою собственную целостность, да, но это не означает, что другой объект не может быть ответственным за сохранение состояния. И геттеры, и сеттеры должны обеспечивать целостность входящих / исходящих данных объекта.
jleach
1
«это не означает, что другой объект не может быть ответственным за сохранение состояния». - Я этого не говорил. Важным утверждением является то, что объект должен быть активным . Это означает, что объект (и никто другой) может делегировать эту операцию другому объекту, но не наоборот: ни один объект не должен просто собирать информацию из пассивного объекта для обработки своей собственной эгоистичной операции (как в случае с репозиторием - с геттерами) , Я попытался реализовать этот подход в фрагментах выше.
ттулка
1
@jleach Вы правы, наше понимание ООП другое, для меня геттеры + сеттеры вообще не являются ООП, иначе мой вопрос не имел бы смысла. Все равно спасибо! :-)
ттулка
1
Вот статья о моей точке зрения: martinfowler.com/bliki/AnemicDomainModel.html Я не повторяю анемическую модель во всех случаях, например, это хорошая стратегия для функционального программирования. Просто не ООП.
ттулка

Ответы:

7

Вы написали

С другой стороны, ООП говорит, что объект Product должен знать, как сохранить себя

и в комментарии.

... должен нести ответственность за все операции, выполненные с ним

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

Так что же может быть разумной работой домена для Product? На самом деле это зависит от предметного контекста прикладной системы. Если система небольшая и поддерживает только операции CRUD, то, действительно, a Productможет оставаться довольно «анемичным», как в вашем примере. Для такого рода приложений это может быть спорным, если размещение операций с базой данных в отдельном классе репо или использование DDD вообще стоит хлопот.

Однако, как только ваше приложение поддерживает реальные бизнес-операции, такие как покупка или продажа продуктов, хранение их на складе и управление ими или расчет налогов для них, довольно часто вы начинаете обнаруживать операции, которые можно разумно поместить в Productкласс. Например, может существовать операция, CalcTotalPrice(int noOfItems)которая вычисляет цену для `n товаров определенного продукта с учетом скидок за объем.

Короче говоря, когда вы разрабатываете классы, вам нужно подумать о своем контексте, в каком из пяти миров Джоэла Спольски вы находитесь, и если система содержит достаточно логики предметной области, так что DDD будет полезным. Если ответ «да», то маловероятно, что вы в конечном итоге получите анемичную модель только потому, что не используете механику персистентности в классах предметной области.

Док Браун
источник
Ваша точка зрения звучит очень разумно для меня. Таким образом, продукт становится анемичной структурой данных при пересечении границы контекста анемичных структур данных (базы данных), а хранилище является шлюзом. Но это все еще означает, что я должен предоставить доступ к внутренней структуре объекта через getter и setters, которые затем становятся частью его API и могут быть легко использованы другим кодом, который не имеет ничего общего с постоянством. Есть хорошая практика, как этого избежать? Спасибо!
ттулка
«Но это все еще означает, что я должен предоставить доступ к внутренней структуре объекта через методы получения и установки» - маловероятно. Внутреннее состояние невосприимчивого доменного объекта обычно определяется исключительно набором атрибутов, связанных с доменом. Для этих атрибутов должны существовать геттеры и сеттеры (или инициализация конструктора), иначе «интересная» операция с доменом невозможна. В некоторых средах также доступны функции персистентности, которые позволяют сохранять частные атрибуты путем отражения, поэтому инкапсуляция нарушается только для этого механизма, а не для «другого кода».
Док Браун
1
Я согласен, что постоянство обычно не является частью операций домена, однако оно должно быть частью «реальных» операций домена внутри объекта, который нуждается в этом. Например Account.transfer(amount)должен сохраниться перевод. Как это происходит, это ответственность объекта, а не какой-то внешней сущности. Отображение объекта с другой стороны , это , как правило , операция домен! Требования обычно подробно описывают, как все должно выглядеть. Это часть языка среди участников проекта, бизнеса или иным образом.
Роберт
@ RobertBräutigam: классика Account.transferдля обычно включает в себя два объекта счета и объект единицы работы. Транзакционная персистентная операция может затем быть частью последней (в дополнение к вызовам связанных репозиториев), поэтому она остается вне метода «передачи». Таким образом, вы Accountможете оставаться настойчивым невежественным. Я не говорю, что это обязательно лучше, чем ваше предполагаемое решение, но ваш также является лишь одним из нескольких возможных подходов.
Док Браун
1
@ RobertBräutigam Уверен, вы слишком много думаете об отношении объекта к столу. Думайте об объекте как о состоянии для себя, всего в памяти. После выполнения переносов в объектах вашей учетной записи вы останетесь с объектами с новым состоянием. Это то, что вы хотели бы сохранить, и, к счастью, объекты аккаунта позволяют вам узнать об их состоянии. Это не означает, что их состояние должно совпадать с таблицами в базе данных - т.е. передаваемая сумма может быть денежным объектом, содержащим необработанную сумму и валюту.
Стив Chamaillard
5

Практика превосходит теорию.

Опыт учит нас, что Product.Save () приводит к множеству проблем. Чтобы обойти эти проблемы, мы изобрели шаблон хранилища.

Конечно, это нарушает правило ООП о сокрытии данных о продукте. Но это работает хорошо.

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

Ewan
источник
3

DDD встречает ООП

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

С другой стороны, ООП говорит, что объект Product должен знать, как сохранить себя.

Не так. Объекты инкапсулируют свои собственные структуры данных. Ваше представление продукта в памяти отвечает за демонстрацию поведения продукта (каким бы оно ни было); но постоянное хранилище находится там (за хранилищем) и имеет свою собственную работу.

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

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

Может быть, мы можем делегировать сохранение другому объекту:

Это, безусловно, работает - ваше постоянное хранилище становится обратным вызовом. Я бы, наверное, сделал интерфейс проще:

interface ProductStorage {
    onProduct(String name, double price);
}

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

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

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

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

Я понимаю DDD как метод ООП и поэтому хочу полностью понять это кажущееся противоречие.

Следует помнить одну вещь - «Синяя книга» была написана пятнадцать лет назад, когда Java 1.4 бродила по земле. В частности, книга предшествует дженерикам Java - у нас сейчас гораздо больше техник, чем когда Эванс разрабатывал свои идеи.

VoiceOfUnreason
источник
2
Также стоит упомянуть: «сохранить себя» всегда будет требовать взаимодействия с другими объектами (либо объектом файловой системы, либо базой данных, либо удаленной веб-службой, некоторые из них могут дополнительно требовать установления сеанса для управления доступом). Поэтому такой объект не будет самостоятельным и независимым. Поэтому ООП может не требовать этого, поскольку его целью является инкапсуляция объекта и уменьшение связи.
Кристоф
Спасибо за отличный ответ. Сначала я разработал Storageинтерфейс так же, как вы, затем я подумал о высокой связи и изменил его. Но вы правы, в любом случае существует неизбежная связь, так почему бы не сделать ее более явной.
ттулка
1
«Этот подход немного противоречит тому, что Эванс описал в« Синей книге » - так что, в конце концов, есть некоторая напряженность :-) В этом и был смысл моего вопроса, я действительно понимаю DDD как метод ООП, и поэтому я хочу полностью понимаю это кажущееся противоречие.
ттулка
1
По моему опыту, каждая из этих вещей (ООП в целом, DDD, TDD, подобрать акроним) сама по себе звучит красиво и хорошо, но всякий раз, когда дело доходит до реализации в "реальном мире", всегда есть некоторый компромисс или меньше идеализма, который должен быть для него работать.
jleach
Я не согласен с понятием, что постоянство (и представление) как-то «особенное». Они не. Они должны быть частью моделирования, чтобы расширить требования требований. Не должно быть искусственной (основанной на данных) границы внутри приложения, если только нет фактических требований об обратном.
Роберт
1

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

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

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

Кроме того, save()объект должен быть разрешен только в том случае, если он является частью домена («язык»). Например, мне не нужно явно «сохранять» Accountпосле вызова transfer(amount). Я должен справедливо ожидать, что бизнес-функция transfer()сохранит мой перевод.

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

Роберт Бройтигам
источник
Твой разговор где-то смотреть? (Я вижу, только слайды по ссылке). Благодарность!
ттулка
У меня есть только запись разговора на немецком языке, здесь: javadevguy.wordpress.com/2018/11/26/…
Роберт
Отличный разговор! (К счастью, я говорю по-немецки). Я думаю, что весь ваш блог стоит прочитать ... Спасибо за вашу работу!
ттулка
Очень проницательный слайдер Роберт. Мне показалось это очень показательным, но у меня сложилось впечатление, что в конце концов многие решения, направленные на неразрывную инкапсуляцию и LoD, основаны на предоставлении большого количества ответственности объекту домена: печать, сериализация, форматирование пользовательского интерфейса и т. Д. t, которые увеличивают связь между доменом и техническим (детали реализации)? Например, AccountNumber в сочетании с API Apache Wicket. Или аккаунт с каким-либо объектом Json? Как вы думаете, это сцепление стоит иметь?
Laiv
@Laiv Грамматика вашего вопроса предполагает, что что-то не так с использованием технологии для реализации бизнес-функций? Скажем так: проблема не в связи между доменом и технологией, а в связи между различными уровнями абстракции. Например, AccountNumber следует знать, что он может быть представлен как TextField. Если бы другие (например, «представление») знали бы об этом, это не должно существовать связывание, потому что этот компонент должен был бы знать, из чего AccountNumberсостоит, то есть внутренности.
Роберт
1

Может быть, мы можем делегировать сохранение другому объекту

Избегайте ненужного распространения знаний о полях. Чем больше вещей известно об отдельном поле, тем сложнее становится добавить или удалить поле:

public class Product {
    private String name;
    private Double price;

    void save(Storage storage) {
        storage.save( toString() );
    }
}

Здесь продукт не имеет понятия, сохраняете ли вы файл журнала, базу данных или и то, и другое. Здесь метод сохранения не имеет понятия, есть ли у вас 4 или 40 полей. Это слабо связано. Это хорошая вещь.

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

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

candied_orange
источник
На самом деле это код, который я разместил в своем вопросе, верно? Я использовала Map, вы предлагаете Stringили List. Но, как @VoiceOfUnreason упомянул в своем ответе, связь все еще там, просто не явно. По-прежнему нет необходимости знать структуру данных продукта, чтобы сохранять ее как в базе данных, так и в файле журнала, по крайней мере, при обратном чтении в виде объекта.
ттулка
Я изменил метод сохранения, но в остальном да, он почти такой же. Разница в том, что связь больше не статична, что позволяет добавлять новые поля без принудительного изменения кода в системе хранения. Это делает систему хранения многоразовой в разных продуктах. Это просто заставляет вас делать то, что кажется немного неестественным, например, превращать двойник в струну и обратно в двойник. Но это также можно обойти, если это действительно проблема.
candied_orange
Смотрите гетерогенную коллекцию
candied_orange
Но, как я уже сказал, я вижу связь по-прежнему (путем синтаксического анализа), только потому, что она не является статической (явной), что приводит к тому, что компилятор не может ее проверить и поэтому подвержен ошибкам. StorageЯвляется частью домена (а также интерфейс хранилища) и делает такую настойчивость API. При изменении лучше информировать клиентов во время компиляции, потому что они все равно должны реагировать, чтобы не сломаться во время выполнения.
ттулка
Это ошибочное мнение. Компилятор не может проверить файл журнала или БД. Все, что он проверяет, так это то, что один файл кода совместим с другим файлом кода, который также не обязательно будет совместим с файлом журнала или БД.
candied_orange
0

Есть альтернатива уже упомянутым моделям. Шаблон Memento отлично подходит для инкапсуляции внутреннего состояния объекта домена. Объект memento представляет собой снимок открытого состояния объекта домена. Доменный объект знает, как создать это общедоступное состояние из его внутреннего состояния и наоборот. Тогда хранилище работает только с публичным представительством государства. При этом внутренняя реализация отделена от любых постоянных особенностей, и она просто должна поддерживать публичный контракт. Также ваш доменный объект не должен выставлять какие-либо методы получения, которые действительно сделали бы его немного анемичным.

Для получения дополнительной информации по этой теме я рекомендую большую книгу Скотта Миллета и Ника Тьюна "Образцы, принципы и практики доменного управления".

Роман Вейс
источник