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

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

Жизненный пример


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

Для начала, представим нашу доменную модель.

У нас есть покупатели, заказы и позиции заказов. Покупатель может иметь много заказов, заказ может иметь много линий. Каждая линия содержит базовую цену за единицу товара, цену за линию, количество, идентификатор товара, ссылку на заказ, в котором линия содержится, а также статус линии (например, «оформлена» или «отменена»). Заказ содержит идентификатор заказа у клиента, дату и время совершения заказа, стоимость заказа с учётом всех скидок, стоимость доставки, ссылку на магазин покупки, а так же ссылку на потребителя.
Так же у нас есть требование просмотра истории изменения заказа и запрет на удаления данных о заказах, которые к нам когда-то загружались. В итоге, получается следующая структура классов:

Покупатель
class Customer
{
	public int Id { get; set; }
	public string Name { get; set; }
	public string Email { get; set; }
}


Заказ
class Order 
{
	public int Id {get;set;}
	public string ExternalId {get;set;}
	public ICollection<OrderHistoryItem> History {get;}
}


Историческое состояние заказа
class OrderHistoryItem 
{
	public ICollection<OrderHistoryLine> OrderLines {get;}
	public Order Order { get; set; }
	public DateTime OrderChangeDateTime { get; set; }
	public decimal TotalAmountAfterDiscounts { get; set; }
	public Customer Customer { get; set; }
	public decimal DeliveryCost { get; set; }
	public string ShopExternalId { get; set; }
}


Позиция заказа
class OrderHistoryLine
{
	public decimal BasePricePerItem { get; set; }
	public OrderHistoryItem OrderHistoryItem { get; set; }
	public decimal PriceOfLine { get; set; }
	public decimal Quantity { get; set; }
	public string Status { get; set; }
	public string ProductExternalId { get; set; }
}


Заказ (Order) — агрегат, в который может входить множество исторических состояний заказа (OrderHistoryItem), каждое из которых содержит линии (OrderHistoryLine). Таким образом, при изменении заказа мы сохраним его состояния до и после изменения. При этом актуальное состояние заказа всегда можно получить, упорядочив исторические состояния по времени и взяв первое.

Теперь опишем формат запроса API, которым могли бы пользоваться наши клиенты для передачи заказов:

Формат API
{
	id: “123”,
	customer: {  id: 1 },
	dateTimeUtc: “21.08.2017 11:11:11”,
	totalAmountAfterDiscounts: 2000,
	shop: “shop-moscow”,
	deliveryCost: 0,
	items: [{
		basePricePerItem: 100,
		productId: “456”,
		status: “Paid”,
		quantity: 20,
		priceOfLineAfterDiscounts: 2000	
	}]
}


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

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

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

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

public void SaveOrder(OrderHistoryItem historyItem) {
	if (historyItem.Order.IsNew)
	{
		SaveOrderCore(historyItem.Order);
		SaveOrderHistoryCore(historyItem);
		return;
	}

	var doesSameHistoryItemExist = historyItem.Order.History
		.Where(h => h != historyItem)
		.Where(h => h.IsEquivalent(historyItem))
		.Any();
		
	if (doesSameHistoryItemExist)
		return;
		
	SaveOrderHistoryCore(historyItem);
}

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

public bool IsEquivalent(OrderHistoryItem otherHistoryItem)
{
	return IsTotalPriceEquivalent(otherHistoryItem) &&
		IsDeliveryCostEquivalent(otherHistoryItem) &&
		IsShopEquivalent(otherHistoryItem) &&
		AreLinesEquivalent(otherHistoryItem);
}

Тесты не спасают


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

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

Представим, что в заказ захотели добавить ещё одно поле: тип оплаты.
Исправим класс OrderHistoryItem:

class OrderHistoryItem c добавленным свойством PaymentType
class OrderHistoryItem 
{
	public Order Order { get; set; }
	public DateTime OrderChangeDateTime { get; set; }
	public decimal TotalAmountAfterDiscounts { get; set; }
	public Customer Customer { get; set; }
	public decimal DeliveryCost { get; set; }
	public string ShopExternalId { get; set; }
	public string PaymentType {get;set;}
}


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

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

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

Декларативные стратегии как выход


Мы написали императивный код проверки свойств заказа, который легко протестировать, но сложно проанализировать. Попробуем применить преимущества декларативного подхода. Сейчас метод IsEquivalent внутри вызывает методы IsTotalPriceEquivalent, IsDeliveryCostEquivalent и т.п. Введем интерфейс стратегии сравнения свойства заказа IOrderHistoryItemPropertyEqualityStrategy:

interface IOrderHistoryItemPropertyEqualityStrategy 
{
	bool ArePropertiesEquivalent(OrderHistoryItem firstOrderHistoryItem, OrderHistoryItem secondOrderHistoryItem);
}

Сделаем по реализации вместо каждого метода сравнения в IsEquivalent:

class TotalPriceOrderHistoryItemPropertyEqualityStrategy : IOrderHistoryItemPropertyEqualityStrategy 
{
	public bool ArePropertiesEquivalent(OrderHistoryItem firstOrderHistoryItem, OrderHistoryItem secondOrderHistoryItem){
		return firstOrderHistoryItem.TotalAmountAfterDiscounts == secondOrderHistoryItem.TotalAmountAfterDiscounts;
	}
}

class DeliveryCostOrderHistoryItemPropertyEqualityStrategy : IOrderHistoryItemPropertyEqualityStrategy
{
	public bool ArePropertiesEquivalent(OrderHistoryItem firstOrderHistoryItem, OrderHistoryItem secondOrderHistoryItem)
	{
		return firstOrderHistoryItem.DeliveryCost == secondOrderHistoryItem.DeliveryCost;
	}
}

и сделаем сравнение более декларативным:

public bool IsEquivalent(OrderHistoryItem otherHistoryItem)
{
	return new TotalPriceOrderHistoryItemPropertyEqualityStrategy().ArePropertiesEquivalent(this, otherHistoryItem) &&
		new DeliveryCostOrderHistoryItemPropertyEqualityStrategy().ArePropertiesEquivalent(this, otherHistoryItem) &&
		new ShopOrderHistoryItemPropertyEqualityStrategy().ArePropertiesEquivalent(this, otherHistoryItem) &&
		new LinesOrderHistoryItemPropertyEqualityStrategy().ArePropertiesEquivalent(this, otherHistoryItem);
}

Немного отрефакторим:

private static IOrderHistoryItemPropertyEqualityStrategy[] equalityStrategies = new[] {
	new TotalPriceOrderHistoryItemPropertyEqualityStrategy(),
	new DeliveryCostOrderHistoryItemPropertyEqualityStrategy(),
	new ShopOrderHistoryItemPropertyEqualityStrategy(),
	new LinesOrderHistoryItemPropertyEqualityStrategy()
}

public bool IsEquivalent(OrderHistoryItem otherHistoryItem)
{
	return equalityStrategies.All(strategy => strategy.ArePropertiesEquivalent(this, otherHistoryItem))
}

Теперь при добавлении нового свойства нужно создавать новую стратегию сравнения свойств и добавлять её в массив. Пока ничего не поменялось, мы всё так же не защищены от ошибки. Но за счет того, что код стал декларативным, мы можем что-нибудь проверить. Было бы здорово иметь возможность понять, за какое свойство какая стратегия отвечает. Тогда мы могли бы получить все свойства у заказа и проверить, что они все покрыты стратегиями. И это довольно несложно доработать. Добавим в интерфейс стратегии свойство PropertyName и реализуем его в каждой из стратегий:

public interface IOrderHistoryItemPropertyEqualityStrategy 
{
	string PropertyName {get;}
	
	bool ArePropertiesEquivalent(OrderHistoryItem firstOrderHistoryItem, OrderHistoryItem secondOrderHistoryItem);
}

public class TotalPriceOrderHistoryItemPropertyEqualityStrategy : IOrderHistoryItemPropertyEqualityStrategy 
{
	public string PropertyName => nameof(OrderHistoryItem.TotalAmountAfterDiscounts);
	
	public bool ArePropertiesEquivalent(OrderHistoryItem firstOrderHistoryItem, OrderHistoryItem secondOrderHistoryItem){
		return firstOrderHistoryItem.TotalAmountAfterDiscounts == secondOrderHistoryItem.TotalAmountAfterDiscounts;
	}
}

Теперь напишем проверку:

public static void CheckOrderHistoryEqualityStrategies() {
	var historyItemPropertyNames = typeof(OrderHistoryItem)
		.GetProperties()
		.Select(p => p.Name);
		
	var equalityStrategiesPropertyNames = equalityStrategies
		.Select(es => es.PropertyName);
		
	var propertiesWithoutCorrespondingStrategy = historyItemPropertyNames
		.Except(equalityStrategiesPropertyNames)
		.ToArray();
		
	if (!propertiesWithoutCorrespondingStrategy.Any())
		return;

	var missingPropertiesString = string.Join(", ", propertiesWithoutCorrespondingStrategy);
	throw new InvalidOperationException($"Для свойств {missingPropertiesString} не зарегистрировано стратегии сравнения свойства");
}

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

public class NoCheckOrderHistoryItemPropertyEqualityStrategy : IOrderHistoryItemPropertyEqualityStrategy
{
	private string propertyName;
	
	public NoCheckOrderHistoryItemPropertyEqualityStrategy(string propertyName) {
		this.propertyName = propertyName;
	}

	public string PropertyName => propertyName;

	public bool ArePropertiesEquivalent(OrderHistoryItem firstOrderHistoryItem, OrderHistoryItem secondOrderHistoryItem)
	{
		return true;
	}
}

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

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

Другие примеры


Удаление потребителей


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

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

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

Провайдеры времени пересчёта условий фильтрации


Ещё один пример той же проблемы: регистрация условий фильтрации. У нас есть довольно гибкий конструктор фильтров в UI:



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

Со временем стало понятно, что этот подход нужно оптимизировать, потому что триггеров много, потребителей много, и каждые 15 минут выбирать их всех тяжело. Оптимизация, которая напрашивалась сама собой — давайте не будем проверять потребителей, которые не изменялись, будем каждый раз смотреть только на изменившихся потребителей. Это был бы вполне валидный подход, если бы не одно но: некоторые условия фильтрации становятся верными для потребителя не из-за того, что с ним что-то произошло, а из-за того, что просто пришло время. Такими условиями являются всяческие временные условия, типа “до дня рождения осталось менее 5 дней”.

Мы выделили все условия фильтрации, истинность которых может измениться от времени, и решили, что они должны сообщать нам время, за которое их имеет смысл пересчитывать. Грубо говоря, если мы считаем в днях, то имеет смысл пересчитывать каждые 12 часов, если в часах — то каждый час.

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

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

В заключение


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

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

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


  1. nerumb
    18.12.2017 10:53

    DeliveryCostOrderHistoryItemPropertyEqualityStrategy

    TotalPriceOrderHistoryItemPropertyEqualityStrategy

    Spring нервно курит в стороне смотря на такие названия :)


  1. MonkAlex
    18.12.2017 11:38

    А почему просто не реализовать эквивалентность для записей, которые надо сравнить?


    1. timramone Автор
      18.12.2017 12:25

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

      Вот это разве не то, что вы предлагаете? IsEquivalent — метод класса OrderHistoryItem, то есть сделано, как вы и предлагаете: реализована эквивалентность записей, которые нужно сравнивать. Проблема в поддержке этой эквивалентности.


      1. ApeCoder
        18.12.2017 14:55

        1. Почему нельзя переопределить стандартный Equals а надо использовать IsEquivalent
        2. Зачем надо вводить новый слой косвенности на стратегиях, если все равно все сводится к тестированию через reflection? Можно было бы просто либо сравнивать через reflection либо просто в тесте перебирать свойства по очереди и их менять и проверять сто при разичии хотя бы в любом одном свойстве IsEquivalent возвращает true.


        1. timramone Автор
          18.12.2017 15:59

          1. Потому что Equals используется нашим ORM (linq to sql) для определения, одна и та же сущность перед ним или нет. Переопределение Equals кодом, который не соответствует сравнению PK сущностей приведет к тому, что ORM перестанет работать :(
          2. Не каждое свойство одинаково участвует в определении эквивалентности. Некоторые свойства не сравниваются вообще (например, даты). Некоторые свойства сравниваются сложным образом, как линии. В простом случае можно сгенерировать код при старте приложения. Но это делает код сложнее, например для дебага. Код, который написан в статье, можно дебажить, рефлексия используется только для проверки.


          1. ApeCoder
            18.12.2017 16:15

            Если рефлексию использовать для проверки, то можно не гененировать "стратегии" а тестировать только IsEquivalent. Подмножество свойств, которые надо сравнивать можно задать либо атрибутами либо выделить в отдельный класс (OrderHeader, OrderState, OrderVersion)


            1. timramone Автор
              18.12.2017 16:26

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


              1. ApeCoder
                18.12.2017 17:18

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


          1. sovaalexandr
            19.12.2017 21:34

            1. Несколько не понятно — если с точки зрения бизнеса или моделируемого вами бизнес-процесса это одна и та же запись (вы же в корне для этого добавляли isEquivalent) — то почему бы ORM считать это разными записями?
            2. Вам надо хранить свойства что не участвуют в вашей бизнес модели? Нет? — чего они тогда не участвуют в эквивалентности. Сравнение сложным образом свойства — точно так же только Equals уже на свойстве вызывается.


            1. timramone Автор
              19.12.2017 21:35

              1. Потому что с инфраструктурной точки зрения это разные объекты.
              2. Не понял вопрос.


  1. Sovetnikov
    18.12.2017 12:48

    Ещё как варинт можно реализовать тест, который через метаданные проверит, что у класса для каждого свойства есть метод Is{property}Equivalent.
    И так же реализовать метод IsEquivalent, который все эти методы будет дёргать.

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

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


    1. timramone Автор
      18.12.2017 13:20

      1. Неймконвеншн будет не статически проверяться, мне, если честно, не очень нравится
      2. Можно ошибиться в том, что не вызвать такой метод в IsEquivalent
      3. В чём именно проще поддержка? В предложенном варианте со стратегиями именно поддержка проще, потому что нужно просто реализовать интерфейс.
      4. Научить IsEquivalent ругаться можно, но лучше делать это не в рантайме, а при инициализации приложения. Тогда и с производительностью не будет проблем.


      1. Sovetnikov
        18.12.2017 14:06

        1. А наличие интерфейсов для всех свойств статически проверяется разве? Тоже только при запуске. Или вы о чём?
        Про не нравится бессмыслено что-либо писать, вкус и цвет…
        2. Как ошибиться? Кто-то намеренно скроет свойство от рефлексии?
        3. В том, что не надо копипастить интерфейсы и добавлять стратегии в массив. Поди вспомни через Н недель где там этот интерфейс найти.
        И не надо прописывать строкой название свойства… это утомительно :)
        4. С производительностью не будет проблем только если вы сгенерируете динамически код IsEquivalent, который рефлексию не будет трогать (динамически сгенерируете всё то, что вы предложили с интерфейсами).
        5. А ещё кодогенерацию можно использовать, которая все эти интерфейсы по IOrderHistoryItem сгенерирует. А методы Is{property}Equivalent будут выкидывать исключения, пока не будту вручную реализованы. Тут вообще «откиньте спинки ваших кресел».

        А ещё в сообщении исключения об отсутствии Is{property}Equivalent разработчику можно было бы давать шаблон для релазиции в виде текста. Скопировал его и реализовал, даже руками название метода писать не надо.


  1. fzn7
    18.12.2017 16:22

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


    1. timramone Автор
      18.12.2017 16:24

      А какая разница, в какой именно момент будет решаться проблема незначащих изменений? Кажется, что чем раньше — тем лучше, нет?


      1. fzn7
        18.12.2017 16:36

        Вам виднее. Мое мнение — лишних данных не бывает, если есть возможность то нужно сохранять.


        1. Deosis
          19.12.2017 07:10

          Тоже заинтересовала такая фильтрация.
          Как поведет себя приложение, если клиент поменяет тип оплаты с А на Б, потом обратно на А. По вашей логике вернуть обратно нельзя.


          1. timramone Автор
            19.12.2017 08:23

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


  1. kolbasik
    18.12.2017 21:58

    Насколько я понял, одной из проблем было решение индепотентности операций. С этим намного легче бороться, если добавить при отправке данных уникальный идентификатор команды { OperationID: GUID, Data: any }, а при повторном принятии команды при RETRY очень легко проверять на дубликаты.


    1. timramone Автор
      19.12.2017 00:05

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

      Да, я об этом немного упомянул


      1. kolbasik
        19.12.2017 11:52

        Я как-то не заметил этот момент, мои извинения :)