20 февраля в 09:05

Union Type, TPT, DDD, ORM и RDBMS


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

Классический пример удачного использования объединений для моделирования бизнес-процессов – корзина и состояние заказа. Пользователь в праве добавлять и убирать товары, пока не оплатил заказ. Но сама операция модификации оплаченного заказа лишена смысла. Также лишена смысла операция Remove для пустой корзины. Тогда логично вместо общего класса Cart определить интерфейс ICartState и объявить по одной реализации для каждого состояния. Более подробно данный подход изложен текстом здесь и в видео-формате вот тут.

Недавно у нас возникла задача спроектировать структуру БД для специализированной CRM/ERP. Первый подход к моделированию договоров оказался не удачным, из-за того что сторонами договоров могут выступать как физические, так и юридические лица из России и других стран мира. ИНН необходим продавцу, чтобы получить оплату, но не всегда нужен полкупателю (для идентификации личности чаще используются паспортные данные). Форматы реквизитов отечественных и зарубежных юр.лиц не совпадают. Не помогало делу и то, что ИП являются физическими лицами, но «прикидываются» юридическими.

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

Первоначальный набросок модели предметной области выглядел так:

    public enum BusinessEntityType
    {
        [Display(Name = "Юр. лицо: ООО, LTD, ...")]
        LegalEntity = 0,

        [Display(Name = "ИП")]
        IndividualEntrepreneur,

        [Display(Name = "Физ. лицо")]
        Person,

        [Display(Name = "Не коммерческое образовательное учреждение")]
        NonProfitEducationalInstitution
    }

    [DisplayName("Компания")]
    public class Contractor : NamedEntityBase
    {
        [Required]
        [Display(Name = "Юридический адрес / Адрес для физ.лица")]
        public Address MainAddress { get; set; }

        [Display(Name = "Фактический адрес адрес")]
        public Address ActualAddress { get; set; }

        [Display(Name = "Форма хозяйствования")]
        public BusinessEntityType Type { get; set; }

        [Display(Name = "Инн"), StringLength(127), RegularExpression("\\d+")]
        public string Inn { get; set; }
        
        [Display(Name = "Электронная почта")]
        public virtual string Email { get; set; }

        [Display(Name = "Телефон")]
        public virtual string PhoneNumber { get; set; }

        public Contractor([NotNull] string name, BusinessEntityType type,
            [NotNull] Address mainAddress, Address actualAddress = null)
            : base(name)
        {
            if (mainAddress == null) throw new ArgumentNullException(nameof(mainAddress));
            Type = type;
            MainAddress = mainAddress;
            ActualAddress = actualAddress;
        }

        protected Contractor()
        {
        }
    }

«Контрагент» явно пытался угодить и «физикам» и «юрикам», что не шло на пользу SRP. Решительно не понятно стало куда девать ОГРНы, БИКи и прочие не интересные физику атрибуты и почему ИНН являетcя обязательным полем для физ. лиц, выступающих покупателями?

Второй подход к снаряду


[DisplayName("Контрагент")]
[Table(nameof(Contractor), Schema = nameof(Office))]
public class Contractor : NamedEntityBase
{
    /// <summary>
    /// Юридический адрес / Адрес для физ.лица
    /// </summary>
    [Required]
    [Display(Name = "Юридический адрес / Адрес для физ.лица")]
    public Address MainAddress { get; set; }

    /// <summary>
    /// Фактический адрес адрес
    /// </summary>
    [Display(Name = "Фактический адрес адрес")]
    public Address ActualAddress { get; set; }

    [Display(Name = "Форма хозяйствования")]
    public ContractorType Type { get; set; }

    [Display(Name = "Инн"), StringLength(127), RegularExpression("\\d+")]
    public string Inn { get; set; }
    
    [Display(Name = "Электронная почта")]
    public string Email { get; set; }

    [Display(Name = "Телефон")]
    public string Phone { get; set; }

    public string FullName => string.Join(" ", Name.Split(','));

    public virtual LegalContact LegalContact { get; set; }

    public virtual PersonContact PersonContact { get; set; }

    public Contractor([NotNull] string name, ContractorType type, [NotNull]Address mainAddress, Address actualAddress = null)
        : base(name)
    {
        if (mainAddress == null) throw new ArgumentNullException(nameof(mainAddress));

        Type = type;
        MainAddress = mainAddress;
        ActualAddress = actualAddress;
    }

    protected Contractor()
    {
    }
}

public class PersonContact : EntityBase, IContact
{
    [Display(Name = "Инн"), StringLength(127), RegularExpression("\\d+")]
    public string Inn { get; set; }

    public string Email { get; set; }

    public string Phone { get; set; }

    public string Name { get; set; }

    public string Surname { get; set; }

    public string Patronymic { get; set; }

    public Address Address { get; set; }
}

public class LegalContact : EntityBase, IContact
{
    [Display(Name = "Инн"), StringLength(127), RegularExpression("\\d+")]
    public string Inn { get; set; }

    public string Kpp { get; set; }

    public string Ogrn { get; set; }

    public string Okpo { get; set; }

    public string Okved { get; set; }

    public string Name { get; set; }

    public string FullName { get; set; }

    public string Email { get; set; }

    public string Phone { get; set; }

    public Address MainAddress { get; set; }

    public Address ActualAddress { get; set; }
}

public interface IContact : IHasId<int>
{
    string Email { get; set; }
}

«Контакты» здесь было бы правильнее назвать «реквизитами». Необходимость интерфейса IContact вызывает вопросы. «Не очень» здесь то, что контроль целостности за LegalContactи PersonContact возможен только применением Check Constraint.

Как бы здесь помог union из LegalContact и PersonContact! К сожалению, такую функциональность реляционные СУБД не предоставляют. Кроме этого поле Type теперь выглядит избыточным. Кейсы ИП и физ.лицо объединены в PersonContact, хотя с точки зрения бизнес-процессов ИП-шники ближе к юр.лицам.

Третий подход к снаряду


Объявим абстрактный класс Contractor, наследников на каждый вид контрагентов и поразмышляем, что их объединяет. Основной вариант использования контрагента – подписание договоров. В данном случае мы не работаем с ЭЦП, поэтому достаточно ФИО и реквизитов сторон, чтобы подставить их в автоматически созданный документ, который будет распечатан и подписан.

    [DisplayName("Контрагент")]
    [Table(nameof(Contractor), Schema = nameof(Office))]
    public class Contractor : EntityBase
    {
        [Display(Name = "Форма хозяйствования")]
        public ContractorType Type { get; set; }

        [Required]
        public string Email { get; set; }

        public string Phone { get; set; }

        public Contractor(ContractorType type) {
            Type = type;
        }

        protected Contractor()
        {
        }
    }

    [Table(nameof(CompanyContractor), Schema = nameof(Office))]
    public class CompanyContractor : Contractor
    {

        [Display(Name = "ИНН/VAT"), StringLength(12)]
        public string Vat { get; set; }

        [Display(Name = "КПП"), StringLength(9)]
        public string Kpp { get; set; }

        [Display(Name = "ОГРН"), StringLength(13)]
        public string Ogrn { get; set; }

        [Display(Name = "ОКПО"), StringLength(10)]
        public string Okpo { get; set; }

        [Display(Name = "ОКВЭД"), StringLength(10)]
        public string Okved { get; set; }

        public string Name { get; set; }

        public string FullName { get; set; }

        public Address MainAddress { get; set; }

        public Address ActualAddress { get; set; }

        public CompanyContractor(string name)
        {
            Name = name;
        }

        protected CompanyContractor()
        {
            
        }
    }

    [Table(nameof(PersonContractor), Schema = nameof(Office))]
    public class PersonContractor : Contractor
    {

        [Display(Name = "Инн"), StringLength(12)]
        public string Vat { get; set; }

        [Display(Name = "ОГРН ИП"), StringLength(15)]
        public string Ogrnip { get; set; }

        public string Name { get; set; }

        public string Surname { get; set; }

        public string Patronymic { get; set; }

        public Address Address { get; set; }

        public string FullName => string.Join(" ", Name, Surname, Patronymic);

        public PersonContractor(string name, string email)
        {
            Name = name;
            Email = email;
        }

        protected PersonContractor()
        {

        }
    }

Можно было бы добавить getter для свойства Type, на случай если вдруг понадобится multiple dispatch. Но следуя YAGNI оставим все как есть.

В качестве ORM мы используем Entity Framework. Данная структура может быть отображена на реляционную базу данных с использованием подхода table per type (TPT).

Осталось всего ничего – перенести «общие» поля в ContractorBase. Теперь базовый класс выглядит логично. В системе доступны реквизиты и представитель контрагента. В случае вопросов можно попробовать связаться по телефону или электронной почте.

Кто такой ИП и с чем его едят


ИП – это форма ведения хозяйственной деятельности без образования юридического лица. Не будем вдаваться в тонкости гражданского кодекса. Остановимся на главном: у ИП есть ОГРНИП (аналог ОГРН) и ИНН. При этом ИНН физ.лица Васи Иванова и ИП Васи Иванова – это один и тот же номер.

Если нас интересуют физ.лица в разрезе трудовых или иных договорных отношений, то мы можем определить еще одну сущность «физ. лицо» и связать ее с контрагентами Контрагент – физ. лицо и ИП. Тогда невозможно будет допустить ошибку при заведении ИП с существующим физ.лицом. Более расширенный справочник физ.лиц может потребоваться в другом контексте (например, для выплаты заработной платы потребуется СНИЛС). Тогда мы сможем повторить «трюк» с TPT и добавить еще по одной таблице на каждый «разрез» в котором нам интересны данные.

Вопрос как определить, что Иванов Иван Иванович из Казани и Иванов Иван Иванович из Пензы — разные люди я рассматривать не буду, потому что эта тема достойна отдельной статьи.

Организация UI


Создание разных контрагентов потребует заполнения разных полей, значит нам потребуется по форме на каждого наследника контрагента. Логично. Если в UI допустимо иметь вместо оного списка контрагентов по списку на каждый тип, то мы можем закончить. Если же удобнее работать с единым списком контрагентов, то придется еще раз кое-что поменять. Выделим интерфейс IContractor и вынесем абстрактные методы туда. Переименуем ContractorBase в ContractorInfo и сделаем этот класс не абстрактным, чтобы иметь возможность вывести список поставщиков. Вернем ContractorType, чтобы отличать типы контрагентов. EntityFramework материализует объекты через Refletion, поэтому спокойно оставляем конструктор и свойства Type и Name protected. Информация о контрагентах не возникает сама по себе, а добавляется только вместе с самим контрагентом. Нам пришлось денормализовать таблицу, что «не очень» с точки зрения consistency и необходимости дополнительных телодвижений при изменении полей, входящих в состав «наименования», зато неплохо для производтельности.

Заключение


Не смотря на то, что union’ы не поддерживаются «из коробки» ни реляционными СУБД, ни C#, с помощью TPT можно проектировать структуру данных любой сложности и разветвленности, гарантируя заполнение всех обязательных полей. Нужно, лишь выделить все возможные кейсы и создать по наследнику на каждый. В самом экстремальном случае (когда между наследниками нет ничего общего) базовый класс будет содержать только Id. Такая таблица выглядит странно, однако так гораздо проще проставлять Foreign Key для связанных таблиц, чем любым другим способом. Кроме этого, подход можно применять рекурсивно и формально, что повышает надежность при проектировании разветвленной модели предметной области.
Максим Аршинов @marshinov
карма
85,0
рейтинг 5,6
Управление разработкой бизнес-приложений
Самое читаемое Разработка

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

  • +1
    Может глупый вопрос, но зачем вам нужно именно это наследование? Вот вы выделили Contractor, и у него есть тип. Так зачем же наследоваться? Почему нельзя сделать отдельные контексты для каждого типа, которые можно получить из специального сервиса?
    • 0
      На контрактор стоят foreign keys из нескольких других таблиц. Без базового класса нельзя поставить foreign keys на любого «контрагента», потому что они хранятся в разных таблицах, потому что у них разный набор обязательных к заполнению атрибутов.
      • +1
        Так я не против базового класса. Я просто за то, что бы заменить механизм наследования, на механизм получения нужных контекстов.
        • 0
          Пример можете привести?
        • 0
          Не понял, нужен пример:)
          • 0
            lailore:
            Так я не против базового класса. Я просто за то, что бы заменить механизм наследования, на механизм получения нужных контекстов.


            Что имеется ввиду под получением контекстов? DDD bounded context?
    • +2

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


      P.S. Это даже не привязываясь к хранению в РСУБД вообще и обеспечению ссылочной целостности малой кровью.

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

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

  • +1
    Да, ИП портят стройную картину мироздания.

    Во второй части, с tpt, обойдены вниманием юрлица иностранных государств, физлица иностранных государств, граждане с видом на жительство…
    • 0
      Нам они не очень нужны, поэтому не разбирались. Подход позволяет создать по наследнику на каждого такого «контрагента» и добавить необходимые свойства.
      • +1
        При появлении новых наследников, баланс плюсов/минусов подхода может поменяться. Необязательно в худшую сторону.
    • +1
      Притом даже очень сильно, т.к. в одной ипостаси физлицо как ИП может приобретать кофе для своего офиса, а в другой — тот же кофе домой…

      • 0

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

        • 0
          Ну да — то бишь выращиваем особенную такую морскую свинку, которая не хрюкает и плавает не очень -)
          • +1

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

            • 0
              Ну так или иначе — маркетологи хотят выделять их в отдельную сущность, они сами хотят документы уровня юрлица ибо УСН15% подразумевает подтвержденные расходы, юристы — туда же — есть тонкие различия в трактовании ЗОЗПП…

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

              p/s/ возможно скоро еще выделятся в отдельную сущность — самозанятые граждане =)
          • +1
            Все зависит от сложности логики в конкретной организации на стороне юрлиц и физлиц.
            Если акцент на юриков, может получиться, что проще отнаследоваться от юрлица, и недостающее откопипастить с физиков.

            Да, есть еще traits. Но и они не панацея.

            И у наследования ИП от физика свои минусы. Насколько я понимаю, при наследовании от физлица, запись по ИП содержит только уникальные для ИП атрибуты, а фио и прочее на физике.
            Как с точки зрения БД отличить покупку Васи от покупки ИП Васи? На сущности Покупка у атрибута Покупатель будет одиковый ID в обоих случаях.

            • 0
              Угу. В том числе и такое… Слегка коряво, но зачастую применимо — представители/доверенные лица: тогда живет ИП как отдельная сущность и живет физик, который может быть представителем ИП имени самого себя…
            • +1

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

              • +1
                Второй вариант реализован, например, в 1C. Отдельно справочник физ.лиц, на который ссылаются сотрудники и контрагенты — «физики».
  • +1

    А чем поведение всех этих типов отличается? Вы нам показали 3-4 abstract data type, которые отличаются только данными, но не поведением в контексте задачи.


    Почему вообще кому-то надо знать детали того, что у организации есть атрибут1, а у физлица — атрибут2? Почему всех контрагентов нельзя подвести под единый интерфейс а-ля IContactSigner с одним методом sign(IContract contract)?

    • 0

      Как минимум могут (или даже должны) отличаться формы договоров. В одном, "и ФИО, одни реквизиты", в другом "и название, другие реквизиты, в лице ФИО, третьи реквизиты, действующего на основании четвертые реквизиты"

      • +1
        Мысль здесь в том, что систему определяет поведение, а не структура данных предметной области или, что еще веселее, способ хранения этих данных. В данном случае, судя по всему, говорили «ООП», а имели в виду «РСУБД». Тех же контрагентов можно было бы лепить билдером, а разницу в формировании контрактов представить стратегиями. В результате избавились бы от этого странного искусственного наследования.
        • 0

          Да лепить можно чем угодно, главное что в предметной области это разные подтипы одного типа "контрагент". Собственно особо вариантов нет, по-моему — или "суперконтрагент", включающий все свойства всех подтипов, а также свойство подтипа, или наследование. Какие-то другие варинты, что в голову приходят будут лишь разновидностью одного или другого. А как на базу маппить дело десятое. Хорошо, если ссылочную целостность можно обеспечить внешним ключом, а нет — так и бог с ним. Может она и не нужна вовсе.

    • 0
      Мы так и сделали, только у нас появилось свойство Signature { get;} у контрагента. Это свойство ушло в IContractor. Но, если бы общего поведения не было, то сделали бы pattern matching по всем наследникам.

      Я акцентировал внимание на том, что версия:
          public virtual LegalContact LegalContact { get; set; }
      
          public virtual PersonContact PersonContact { get; set; }
      

      была хуже финального варианта с точки зрения хранения данных

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