Сущность

Паттерны проектирования / Паттерны чистой архитектуры
image

uses System.SysUtils, System.Generics.Collections;

type
  //entity: a value with no identity of its own
  TOrderLine = record
    Sku: string;
    Price: Integer;
    Count: Integer;
    function Cost: Integer;
  end;

  //entity: an identity and the rules around it
  TOrder = class
  private
    FId: Integer;
    FLines: TList<TOrderLine>;
    FPaid: Boolean;
  public
    constructor Create(AId: Integer);
    destructor Destroy; override;
    procedure Add(const Sku: string;
      Price, Count: Integer);
    function Size: Integer;
    function Total: Integer;
    function Discount: Integer;
    function Payable: Integer;
    procedure Pay;
    property Id: Integer read FId;
    property Paid: Boolean read FPaid;
  end;

function TOrderLine.Cost: Integer;
begin
  Result := Price * Count;
end;

constructor TOrder.Create(AId: Integer);
begin
  FId := AId;
  FLines := TList<TOrderLine>.Create;
end;

destructor TOrder.Destroy;
begin
  FLines.Free;
  inherited;
end;

procedure TOrder.Add(const Sku: string;
  Price, Count: Integer);
var
  Line: TOrderLine;
begin
  if Count <= 0 then
    raise EArgumentException.Create('bad count');
  Line.Sku := Sku;
  Line.Price := Price;
  Line.Count := Count;
  FLines.Add(Line);
end;

function TOrder.Size: Integer;
begin
  Result := FLines.Count;
end;

function TOrder.Total: Integer;
var
  Line: TOrderLine;
begin
  Result := 0;
  for Line in FLines do
    Inc(Result, Line.Cost);
end;

//business rule: 10% off from 200
function TOrder.Discount: Integer;
begin
  Result := 0;
  if Total >= 200 then
    Result := Total div 10;
end;

function TOrder.Payable: Integer;
begin
  Result := Total - Discount;
end;

//business rule: an empty order is never paid
procedure TOrder.Pay;
begin
  if FLines.Count = 0 then
    raise EInvalidOpException.Create('empty order');
  FPaid := True;
end;

//client code
//no database and no screen anywhere above
var
  Order: TOrder;
begin
  Order := TOrder.Create(1001);
  try
    Order.Add('pen', 30, 2);
    Order.Add('lamp', 190, 1);
    WriteLn('order ', Order.Id);
    WriteLn('lines ', Order.Size);
    WriteLn('total ', Order.Total);
    WriteLn('discount ', Order.Discount);
    WriteLn('payable ', Order.Payable);
    Order.Pay;
    WriteLn('paid ', Order.Paid);
  finally
    Order.Free;
  end;

  //a line that breaks the rule never gets into the order,
  //so the order below stays empty and is refused too
  Order := TOrder.Create(1002);
  try
    try
      Order.Add('pen', 30, 0);
    except
      on E: EArgumentException do
        WriteLn('refused: ', E.Message);
    end;
    try
      Order.Pay;
    except
      on E: EInvalidOpException do
        WriteLn('refused: ', E.Message);
    end;
  finally
    Order.Free;
  end;
  //printed: order 1001
  //         lines 2
  //         total 250
  //         discount 25
  //         payable 225
  //         paid TRUE
  //         refused: bad count
  //         refused: empty order
end.


Сущность держит правило предметной области и ничего не знает ни о базе, ни об экране, ни о сети, поэтому её можно проверить без них.
Read more

Задача. Магазин даёт скидку 10% на любой заказ от 200. Если это правило живёт на веб-странице и ещё раз в запросе к базе данных, две копии скоро разойдутся. Чтобы проверить правило, приходится запускать страницу или базу. Самое важное знание магазина разбросано, и проверить его трудно.

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

Как читать пример. Сущностей две: позиция заказа и сам заказ. Заказ складывает свои позиции, даёт скидку 10% при сумме от 200 и считает сумму к оплате. Пока заказ пуст, он отказывается принимать оплату. Клиентский код собирает заказ 1001 из двух позиций и печатает его числа: итог 250, скидка 25, к оплате 225. Ещё он пытается оплатить пустой заказ, и сущность отказывает. В некоторых языках сущность - не класс, а обычное значение с функциями; роль у неё та же.

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

Частая ошибка. Сущность, которая сама сохраняет себя в базу данных или оформляет себя для экрана. Тогда она зависит от внешнего кода, и её правила уже нельзя проверить отдельно.