Объединения и 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