Твердий, уникаючи анемічних доменів, введення залежності?


33

Хоча це може бути агностичним питанням мови програмування, мені цікаві відповіді, спрямовані на екосистему .NET.

Такий сценарій: припустимо, нам потрібно розробити просту консольну програму для публічної адміністрації. У заяві йдеться про податок на транспортні засоби. Вони (лише) мають такі ділові правила:

1.а) Якщо транспортний засіб є автомобілем і останній раз, коли його власник сплачував податок, було 30 днів тому, тоді власник повинен сплатити ще раз.

1.b) Якщо транспортний засіб є мотоциклом і останній раз його власник сплачував податок було 60 днів тому, тоді власник повинен сплатити ще раз.

Іншими словами, якщо у вас є машина, ви повинні платити кожні 30 днів або якщо у вас є мотоцикл, ви повинні платити кожні 60 днів.

Для кожного транспортного засобу в системі додаток повинен перевірити ці правила та надрукувати ті транспортні засоби (номер таблички та інформацію про власника), які їх не задовольняють.

Я хочу:

2.a) Відповідати принципам SOLID (особливо принципу відкритого / закритого).

Що я не хочу (я думаю):

2.b) Анемічна область, тому ділова логіка повинна діяти всередині суб'єктів господарювання.

Я почав з цього:

public class Person
// You wanted a banana but what you got was a gorilla holding the banana and the entire jungle.
{
    public string Name { get; set; }

    public string Surname { get; set; }
}

public abstract class Vehicle
{
    public string PlateNumber { get; set; }

    public Person Owner { get; set; }

    public DateTime LastPaidTime { get; set; }

    public abstract bool HasToPay();
}

public class Car : Vehicle
{
    public override bool HasToPay()
    {
        return (DateTime.Today - this.LastPaidTime).TotalDays >= 30;
    }
}

public class Motorbike : Vehicle
{
    public override bool HasToPay()
    {
        return (DateTime.Today - this.LastPaidTime).TotalDays >= 60;
    }
}

public class PublicAdministration
{
    public IEnumerable<Vehicle> GetVehiclesThatHaveToPay()
    {
        return this.GetAllVehicles().Where(vehicle => vehicle.HasToPay());
    }

    private IEnumerable<Vehicle> GetAllVehicles()
    {
        throw new NotImplementedException();
    }
}

class Program
{
    static void Main(string[] args)
    {
        PublicAdministration administration = new PublicAdministration();
        foreach (var vehicle in administration.GetVehiclesThatHaveToPay())
        {
            Console.WriteLine("Plate number: {0}\tOwner: {1}, {2}", vehicle.PlateNumber, vehicle.Owner.Surname, vehicle.Owner.Name);
        }
    }
}

2.a: принцип відкритості / закритості гарантується; якщо вони хочуть податок на велосипед, який ви просто успадкуєте від Транспортного засобу, тоді ви перекриєте метод HasToPay і все закінчено. Відкритий / закритий принцип задовольняється простим успадкуванням.

"Проблеми":

3.a) Чому транспортний засіб повинен знати, якщо він повинен платити? Чи не стосується PublicAdministration? Якщо правила державного управління щодо зміни податку на автомобіль змінюються, чому автомобіль повинен змінюватися? Я думаю, ви повинні запитати: "тоді чому ви помістите метод HasToPay всередину транспортного засобу?", І відповідь: тому що я не хочу перевіряти тип транспортного засобу (typeof) всередині PublicAdministration. Ви бачите кращу альтернативу?

3.b) Можливо, сплата податку викликає занепокоєння транспортним засобом, або, можливо, мій початковий проект "Особистість-транспортний засіб-автомобіль-мотоцикл-громадське управління" просто невірний, або мені потрібен філософ і кращий бізнес-аналітик. Рішенням може бути: перенести метод HasToPay до іншого класу, ми можемо назвати це TaxPayment. Потім ми створюємо два похідні класи TaxPayment; CarTaxPayment та MotorbikeTaxPayment. Потім ми додаємо абстрактне майно платежу (типу TaxPayment) до класу Vehicle і повертаємо потрібний екземпляр TaxPayment з класів Автомобіль та Мотоцикл:

public abstract class TaxPayment
{
    public abstract bool HasToPay();
}

public class CarTaxPayment : TaxPayment
{
    public override bool HasToPay()
    {
        return (DateTime.Today - this.LastPaidTime).TotalDays >= 30;
    }
}

public class MotorbikeTaxPayment : TaxPayment
{
    public override bool HasToPay()
    {
        return (DateTime.Today - this.LastPaidTime).TotalDays >= 60;
    }
}

public abstract class Vehicle
{
    public string PlateNumber { get; set; }

    public Person Owner { get; set; }

    public DateTime LastPaidTime { get; set; }

    public abstract TaxPayment Payment { get; }
}

public class Car : Vehicle
{
    private CarTaxPayment payment = new CarTaxPayment();
    public override TaxPayment Payment
    {
        get { return this.payment; }
    }
}

public class Motorbike : Vehicle
{
    private MotorbikeTaxPayment payment = new MotorbikeTaxPayment();
    public override TaxPayment Payment
    {
        get { return this.payment; }
    }
}

І ми викликаємо старий процес таким чином:

    public IEnumerable<Vehicle> GetVehiclesThatHaveToPay()
    {
        return this.GetAllVehicles().Where(vehicle => vehicle.Payment.HasToPay());
    }

Але тепер цей код не буде компілюватися, оскільки в CarTaxPayment / MotorbikeTaxPayment / TaxPayment немає жодного учасника LastPaidTime. Я починаю бачити CarTaxPayment та MotorbikeTaxPayment більше, ніж "впровадження алгоритму", а не суб'єкти господарювання. Якимось чином цим "алгоритмам" потрібно значення LastPaidTime. Звичайно, ми можемо передати це значення конструктору TaxPayment, але це порушило б інкапсуляцію транспортних засобів, або відповідальність, або як би це не називали євангелісти ООП, чи не так?

3.c) Припустимо, у нас вже є Entity Framework ObjectContext (який використовує наші об’єкти домену; Person, Vehicle, Car, Motorbike, PublicAdministration). Як би ви зробили, щоб отримати посилання на ObjectContext від методу PublicAdministration.GetAllVehicles і, отже, реалізувати функціональність?

3.d) Якщо нам також потрібна та сама посилання на ObjectContext для CarTaxPayment.HasToPay, але інша для MotorbikeTaxPayment.HasToPay, яка відповідає за "введення" або як би ви передали ці посилання на класи платежів? У цьому випадку уникнення анемічного домену (і, отже, жодних об’єктів обслуговування) не приведе нас до катастрофи?

Які ваші варіанти дизайну для цього простого сценарію? Зрозуміло, що приклади, які я показав, "занадто складні" для легкого завдання, але в той же час ідея полягає в дотриманні принципів SOLID і запобігання анемічним доменам (з яких було доручено знищити).

Відповіді:


15

Я б сказав, що ні людина, ні транспортний засіб не повинні знати, чи належить виплата.

перегляньте проблемний домен

Якщо ви подивитесь на проблемний домен "люди, що мають автомобілі": Для придбання автомобіля у вас є якийсь договір, який передає право власності від однієї особи на іншу, ні автомобіль, ні особа не змінюються в цьому процесі. Вашої моделі цього зовсім не вистачає.

мислительний експеримент

Припускаючи, що є подружня пара, чоловік є власником машини, і він сплачує податки. Зараз чоловік помирає, дружина успадковує машину і сплатить податки. І все-таки ваші тарілки залишатимуться такими ж (принаймні в моїй країні вони будуть). Це все одно та сама машина! Ви повинні мати можливість моделювати це у вашому домені.

=> Власність

Автомобіль, який ніким не належить, все ще є автомобілем, але податки за нього ніхто не платить. Насправді ви не сплачуєте податки за автомобіль, а за привілей володіння автомобілем . Отже, моя пропозиція буде такою:

public class Person
{
    public string Name { get; set; }

    public string Surname { get; set; }

    public List<Ownership> getOwnerships();

    public List<Vehicle> getVehiclesOwned();
}

public abstract class Vehicle
{
    public string PlateNumber { get; set; }

    public Ownership currentOwnership { get; set; }

}

public class Car : Vehicle {}

public class Motorbike : Vehicle {}

public abstract class Ownership
{
    public Ownership(Vehicle vehicle, Owner owner);

    public Vehicle Vehicle { get;}

    public Owner CurrentOwner { get; set; }

    public abstract bool HasToPay();

    public DateTime LastPaidTime { get; set; }

   //Could model things like payment history or owner history here as well
}

public class CarOwnership : Ownership
{
    public override bool HasToPay()
    {
        return (DateTime.Today - this.LastPaidTime).TotalDays >= 30;
    }
}

public class MotorbikeOwnership : Ownership
{
    public override bool HasToPay()
    {
        return (DateTime.Today - this.LastPaidTime).TotalDays >= 60;
    }
}

public class PublicAdministration
{
    public IEnumerable<Ownership> GetVehiclesThatHaveToPay()
    {
        return this.GetAllOwnerships().Where(HasToPay());
    }

}

Це далеко не ідеально, і, мабуть, навіть не коректно віддалений код C #, але я сподіваюся, що ви знайдете ідею (і хтось міг би це почистити). Єдиним недоліком є ​​те, що вам, мабуть, знадобиться фабрика або щось, щоб створити правильне CarOwnershipміж a Carі aPerson


Я думаю, що сам транспортний засіб повинен фіксувати сплачені податки за нього, а люди повинні фіксувати сплачені податки за всі свої транспортні засоби. Податковий код може бути записаний так, що якщо податок на автомобіль сплачується 1 червня, а він продається 10 червня, новий власник може стати відповідальним за податок 10 червня, 1 липня або 10 липня (і навіть якщо це в даний час один спосіб може змінитися). Якщо правило на 30 днів є постійним для автомобіля навіть через зміни власності, але автомобілі, яким ніхто не повинен сплатити податки, я б запропонував, що коли автомобіль, який був невідомий протягом 30 і більше днів, сплата податку ...
supercat

... запишіться від вигаданої особи "Невідомий податковий кредит на транспортний засіб", так що станом на момент продажу податкове зобов'язання становитиме або нуль, або тридцять днів, незалежно від того, як довго автомобіль був невідомим.
supercat

4

Я думаю, що в такій ситуації, коли ви хочете, щоб правила бізнесу існували поза цільовими об'єктами, тестування на тип є більш ніж прийнятним.

Безумовно, у багатьох ситуаціях слід використовувати патерн стратегії і дозволити об'єктам самостійно обробляти речі, але це не завжди бажано.

У реальному світі діловодитель дивився на тип транспортного засобу та шукав би свої податкові дані на основі цього. Чому ваше програмне забезпечення не повинно дотримуватися того самого правила busienss?


Гаразд, скажіть, ви переконали мене і за методом GetVehiclesThatHaveToPay ми перевіряємо тип транспортного засобу. Хто повинен відповідати за LastPaidTime? Чи проблема LastPaidTime - це транспортний засіб або PublicAdministration? Тому що якщо це транспортний засіб, чи не повинна ця бізнес-логіка перебувати в транспортному засобі? Що ви можете сказати мені щодо питання 3.c?

@DavidRobertJones - В ідеалі я б шукав останній платіж у журналі історії платежів на основі ліцензії транспортного засобу. Податковий платіж - це податковий концерн, а не транспортний засіб.
Ерік Функенбуш

5
@DavidRobertJones Я думаю, що ви плутаєте себе з власною метафорою. Те, що у вас є, не є фактичним транспортним засобом, який повинен виконувати все, що робить автомобіль. Замість цього ви назвали для простоти TaxableVehicle (або щось подібне) транспортним засобом. Весь ваш домен - це податкові платежі. Не хвилюйтеся, що роблять фактичні транспортні засоби. І, якщо потрібно, киньте метафору і придумайте більш відповідну.

3

Що я не хочу (я думаю):

2.b) Анемічний домен, що означає ділову логіку всередині суб'єктів господарювання.

Ви маєте це назад. Анемічний домен - це той, що логіка знаходиться поза суб'єктами господарювання. Є багато причин, чому використання додаткових класів для реалізації логіки є поганою ідеєю; і лише пара, чому ви повинні це робити.

Звідси і причина, чому його називають анемічним: клас не має нічого в ньому ..

Що я б робив:

  1. Змініть свій абстрактний клас Автомобіль на інтерфейс.
  2. Додайте інтерфейс ITaxPayment, який транспортні засоби повинні реалізувати. Попросіть ваш HasToPay висіти тут.
  3. Видаліть класи MotorbikeTaxPayment, CarTaxPayment, TaxPayment. Вони - зайві ускладнення / захаращення.
  4. Кожен тип транспортного засобу (автомобіль, велосипед, будинок для дому) повинен реалізовувати як мінімум транспортного засобу, а якщо він обкладається податком, також повинен застосовувати ITaxPayment. Таким чином можна розмежувати ті транспортні засоби, які не оподатковуються, та ті, які є ... Припустимо, що така ситуація навіть існує.
  5. Кожен тип транспортного засобу повинен реалізовувати в межах самого класу методи, визначені у ваших інтерфейсах.
  6. Крім того, додайте останню дату платежу в свій інтерфейс ITaxPayment.

Ти правий. Спочатку питання було не сформульовано таким чином, я видалив частину, і значення змінилося. Це зараз виправлено. Спасибі!

2

Чому транспортний засіб повинен знати, якщо він повинен платити? Чи не стосується PublicAdministration?

Ні. Як я розумію, вся ваша програма стосується оподаткування. Тож стурбованість питанням того, чи повинен транспортний засіб оподатковуватись, це вже не стурбованість PublicAdministration, ніж будь-що інше у всій програмі. Немає підстав розміщувати його деінде, але тут.

public class Car : Vehicle
{
    public override bool HasToPay()
    {
        return (DateTime.Today - this.LastPaidTime).TotalDays >= 30;
    }
}

public class Motorbike : Vehicle
{
    public override bool HasToPay()
    {
        return (DateTime.Today - this.LastPaidTime).TotalDays >= 60;
    }
}

Ці два фрагменти коду відрізняються лише постійною. Замість зміни функції всієї функції HasToPay () замініть int DaysBetweenPayments()функцію. Зателефонуйте, що замість того, щоб використовувати константу. Якщо це все, на чому вони насправді відрізняються, я б кинув заняття.

Я також припускаю, що мотоцикл / автомобіль повинен бути властивістю категорії транспортного засобу, а не підкласами. Це дає вам більше гнучкості. Наприклад, це дозволяє легко змінити категорію мотоцикла чи машини. Звичайно, велосипеди навряд чи перетворяться на автомобілі в реальному житті. Але ви знаєте, що транспортний засіб, який увійде неправильно, його потрібно змінити пізніше.

Звичайно, ми можемо передати це значення конструктору TaxPayment, але це порушило б інкапсуляцію транспортних засобів, або відповідальність, або як би це не називали євангелісти ООП, чи не так?

Не зовсім. Об'єкт, який навмисно передає свої дані іншому об'єкту як параметр, не викликає великого занепокоєння. Справжню стурбованість викликають інші об'єкти, що потрапляють всередину цього об'єкта для витягування даних або маніпулювання даними всередині об'єкта.


1

Ви можете бути на правильному шляху. Проблема полягає у тому, що ви помітили, що в TaxPayment немає жодного учасника LastPaidTime . Саме тому він і належить, хоча його взагалі не потрібно публічно викривати:

public interface ITaxPayment
{
    // Your base TaxPayment class will know the 
    // next due date. But you can easily create
    // one which uses (for example) fixed dates
    bool IsPaymentDue();
}

public interface IVehicle
{
    string Plate { get; }
    Person Owner { get; }
    ITaxPayment LastTaxPayment { get; }
} 

Кожен раз, коли ви отримуєте платіж, ви створюєте новий екземпляр ITaxPaymentвідповідно до чинної політики, яка може мати зовсім інші правила, ніж попередня.


Вся справа в тому, що належна оплата залежить від останнього часу сплати власника (різні транспортні засоби, різні терміни платежу). Як ITaxPayment знає, який останній раз платили транспортний засіб (або власник), щоб зробити розрахунок?

1

Я погоджуюся з відповіддю @sebastiangeiger, що проблема справді стосується власності транспортних засобів, а не транспортних засобів. Я пропоную використовувати його відповідь, але з кількома змінами. Я зробив би Ownershipклас загальним класом:

public class Vehicle {}

public abstract class Ownership<T>
{
    public T Item { get; set; }

    public DateTime LastPaidTime { get; set; }

    public abstract TimeSpan PaymentInterval { get; }

    public virtual bool HasToPay
    {
        get { return (DateTime.Today - this.LastPaidTime) >= this.PaymentInterval; }
    }
}

І використовуйте спадщину, щоб вказати інтервал оплати для кожного типу транспортного засобу. Я також пропоную використовувати сильно набраний TimeSpanклас замість простих цілих чисел:

public class CarOwnership : Ownership<Vehicle>
{
    public override TimeSpan PaymentInterval
    {
        get { return new TimeSpan(30, 0, 0, 0); }
    }
}

public class MotorbikeOwnership : Ownership<Vehicle>
{
    public override TimeSpan PaymentInterval
    {
        get { return new TimeSpan(60, 0, 0, 0); }
    }
}

Тепер ваш фактичний код повинен мати справу лише з одним класом - Ownership<Vehicle>.

public class PublicAdministration
{
    List<Ownership<Vehicle>> VehicleOwnerships { get; set; }

    public IEnumerable<Vehicle> GetVehiclesThatHaveToPay()
    {
        return VehicleOwnerships.Where(x => x.HasToPay).Select(x => x.Item);
    }
}

0

Я ненавиджу порушувати це вам, але у вас є анемічний домен , тобто бізнес-логіка поза об'єктами. Якщо це те, що ви збираєтеся, це добре, але ви повинні прочитати про причини, щоб цього уникати.

Намагайтеся не моделювати домен проблеми, а моделюйте рішення. Ось чому ви закінчуєте паралельну ієрархію успадкування та більшу складність, ніж вам потрібно.

Щось подібне - це все, що вам потрібно для цього прикладу:

public class Vehicle
{
    public Boolean isMotorBike()
    public string PlateNumber { get; set; }
    public String Owner { get; set; }
    public DateTime LastPaidTime { get; set; }
}

public class TaxPaymentCalculator
{
    public List<Vehicle> GetVehiclesThatHaveToPay(List<Vehicle> all)
}

Якщо ви хочете слідувати SOLID, це чудово, але, як сказав сам дядько Боб, це лише принципи інженерії . Вони не призначені для суворого застосування, але є рекомендаціями, які слід враховувати.


4
Я думаю, що ваше рішення не є особливо масштабним. Вам потрібно буде додати новий булевий тип для кожного типу транспортного засобу, який ви додаєте. На мій погляд, це поганий вибір.
Ерік Функенбуш

@Garrett Hall, мені сподобалось, що ти видалив клас Person з мого "домену". Я б не мав цього класу в першу чергу, так шкода цього. Але isMotorBike не має сенсу. Яка була б реалізація?

1
@DavidRobertJones: Я погоджуюся з Mystere, додавання isMotorBikeметоду не є гарним вибором дизайну OOP. Це як заміна поліморфізму кодом типу (хоч булевим).
Гроо

@MystereMan, можна було легко кинути булі і використовуватиenum Vehicles
radarbob

+1. Намагайтеся не моделювати домен проблеми, а моделюйте рішення . Піті . Пам'ятно сказано. І принципи коментують. Я бачу занадто багато спроб суворого буквального тлумачення ; нісенітниця.
radarbob

0

Я думаю, ти маєш рацію, що термін оплати не є власністю фактичного Автомобіля / Мотоцикла. Але це атрибут класу транспортного засобу.

Хоча ви могли піти по дорозі, якщо матимете if / select на Тип об'єкта, це не дуже масштабовано. Якщо пізніше ви додасте вантажний автомобіль і Segway і підкажете велосипед, автобус і літак (це може здатися малоймовірним, але ви ніколи не знаєте з громадськими службами), тоді стан стане більшим. І якщо ви хочете змінити правила для вантажного автомобіля, вам доведеться знайти його в цьому списку.

То чому б не зробити його, абсолютно буквально, атрибутом і використовувати рефлексію, щоб витягнути його? Щось на зразок цього:

[DaysBetweenPayments(30)]
public class Car : Vehicle
{
    // actual properties of the physical vehicle
}

[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public class DaysBetweenPaymentsAttribute : Attribute, IPaymentRequiredCalculator
{
    private int _days;

    public DaysBetweenPaymentAttribute(int days)
    {
        _days = days;
    }

    public bool HasToPay(Vehicle vehicle)
    {
        return vehicle.LastPaid.AddDays(_days) <= DateTime.Now;
    }
}

Ви також можете перейти зі статичною змінною, якщо це зручніше.

Мінуси є

  • що це буде трохи повільніше, і я припускаю, що вам доведеться обробляти багато транспортних засобів. Якщо це питання, то я можу взяти більш прагматичну точку зору і дотримуватися абстрактної властивості чи взаємопов'язаного підходу. (Хоча я б також розглядав кешування за типом.)

  • Вам потрібно буде перевірити, що кожен підклас транспортного засобу має атрибут IPaymentRequiredCalculator (або дозволити за замовчуванням), тому що ви не хочете, щоб він обробляв 1000 автомобілів, а тільки зазнав аварії, оскільки для мотоцикла немає періоду платежу.

Переваги полягають у тому, що якщо ви пізніше натрапите на календарний місяць або річний платіж, ви можете просто написати новий клас атрибутів. Це саме визначення OCP.


Основна проблема з цим полягає в тому, що якщо ви хочете змінити кількість днів між платежами за транспортний засіб, вам потрібно буде переробити і перерозподілити, і якщо частота платежів змінюється залежно від властивості суб'єкта господарювання (наприклад, розмір двигуна), це буде важко отримати для цього елегантний виправлення.
Ед Джеймс

@EdWoodcock: Я думаю, що перше питання стосується більшості рішень, і це насправді не хвилює мене. Якщо згодом вони захочуть такого рівня гнучкості, я б надіслав їм додаток БД. З другого пункту я повністю не згоден. У цей момент ви просто додаєте автомобіль як необов'язковий аргумент до HasToPay () і ігноруєте його там, де він вам не потрібен.
пдр

Я думаю, це залежить від довгострокових планів програми для додатка, а також особливих вимог клієнта щодо того, чи варто підключати БД, якщо такої ще немає (я вважаю, що майже все програмне забезпечення - це БД -дрівен, який я знаю, не завжди так). Однак на другому пункті важливий класифікатор витончений ; Я б не сказав, що додавання додаткового параметра до методу на атрибут, який потім приймає екземпляр класу, до якого додається атрибут, є особливо елегантним рішенням. Але, знову ж таки, це залежить від ваших вимог клієнта.
Ед Джеймс

@EdWoodcock: Насправді я лише думав про це, і останній аргументPaid вже повинен бути власністю транспортного засобу. Маючи на увазі ваш коментар, можливо, варто замінити цей аргумент зараз, а не пізніше - редагувати.
пдр
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.