Сущность

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

//entity: a refusal is an NSError, not an exception;
//under ARC a thrown exception leaks what it unwinds
BOOL refuse(NSError **error, NSString *reason) {
    if (error) {
        *error = [NSError errorWithDomain:@"shop" code:1
            userInfo:@{NSLocalizedDescriptionKey: reason}];
    }
    return NO;
}

//entity
@interface OrderLine : NSObject
//copy, not strong: a mutable string handed in stays
//the caller's, and a later change of it cannot reach
//into the order
@property (nonatomic, copy, readonly) NSString *sku;
@property (nonatomic, readonly) NSInteger price;
@property (nonatomic, readonly) NSInteger count;
@property (nonatomic, readonly) NSInteger cost;

- (instancetype)initWithSku:(NSString *)sku
                      price:(NSInteger)price
                      count:(NSInteger)count;
@end

@implementation OrderLine

- (instancetype)initWithSku:(NSString *)sku
                      price:(NSInteger)price
                      count:(NSInteger)count {
    self = [super init];
    if (self) {
        _sku = [sku copy];
        _price = price;
        _count = count;
    }
    return self;
}

- (NSInteger)cost {
    return self.price * self.count;
}
@end

//entity
@interface Order : NSObject
@property (nonatomic, readonly) NSInteger number;
//an immutable array, replaced on every add: what the
//outside was given never changes behind the rules
@property (nonatomic, copy, readonly)
    NSArray<OrderLine *> *lines;
@property (nonatomic, readonly) NSInteger total;
@property (nonatomic, readonly) NSInteger discount;
@property (nonatomic, readonly) NSInteger payable;
@property (nonatomic, readonly) BOOL paid;

- (instancetype)initWithNumber:(NSInteger)number;
- (BOOL)addSku:(NSString *)sku
         price:(NSInteger)price
         count:(NSInteger)count
         error:(NSError **)error;
- (BOOL)pay:(NSError **)error;
@end

@implementation Order

- (instancetype)initWithNumber:(NSInteger)number {
    self = [super init];
    if (self) {
        _number = number;
        _lines = @[];
    }
    return self;
}

- (BOOL)addSku:(NSString *)sku
         price:(NSInteger)price
         count:(NSInteger)count
         error:(NSError **)error {
    if (count <= 0) {
        return refuse(error, @"bad count");
    }
    OrderLine *line = [[OrderLine alloc] initWithSku:sku
                                               price:price
                                               count:count];
    _lines = [_lines arrayByAddingObject:line];
    return YES;
}

//a key path sums the lines without a loop; the price
//is that the compiler does not check the word "cost"
- (NSInteger)total {
    return [[_lines valueForKeyPath:@"@sum.cost"]
            integerValue];
}

//business rule: 10% off from 200
- (NSInteger)discount {
    return self.total >= 200 ? self.total / 10 : 0;
}

- (NSInteger)payable {
    return self.total - self.discount;
}

//business rule: an empty order is never paid
- (BOOL)pay:(NSError **)error {
    if (_lines.count == 0) {
        return refuse(error, @"empty order");
    }
    _paid = YES;
    return YES;
}
@end

//client code
//no database and no screen anywhere above
Order *order = [[Order alloc] initWithNumber:1001];
NSMutableString *sku = [@"pen" mutableCopy];
[order addSku:sku price:30 count:2 error:NULL];
[order addSku:@"lamp" price:190 count:1 error:NULL];
[sku appendString:@"cil"];

NSLog(@"order %ld", (long)order.number);
NSLog(@"lines %lu", (unsigned long)order.lines.count);
NSLog(@"first %@, caller has %@",
      order.lines.firstObject.sku, sku);
NSLog(@"total %ld", (long)order.total);
NSLog(@"discount %ld", (long)order.discount);
NSLog(@"payable %ld", (long)order.payable);
[order pay:NULL];
NSLog(@"paid %d", order.paid);

//the rules refuse bad data, and nobody else has to
NSError *error = nil;
if (![order addSku:@"pen" price:30 count:0 error:&error]) {
    NSLog(@"refused: %@", error.localizedDescription);
}
Order *empty = [[Order alloc] initWithNumber:1002];
if (![empty pay:&error]) {
    NSLog(@"refused: %@", error.localizedDescription);
}
//printed: order 1001
//         lines 2
//         first pen, caller has pencil
//         total 250
//         discount 25
//         payable 225
//         paid 1
//         refused: bad count
//         refused: empty order


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

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

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

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

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

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