SOLIDO, evitando domini anemici, iniezione di dipendenza?


33

Anche se questa potrebbe essere una domanda agnostica del linguaggio di programmazione, sono interessato a risposte rivolte all'ecosistema .NET.

Questo è lo scenario: supponiamo di dover sviluppare una semplice applicazione console per la pubblica amministrazione. L'applicazione riguarda l'imposta sui veicoli. Hanno (solo) le seguenti regole aziendali:

1.a) Se il veicolo è un'auto e l'ultima volta che il proprietario ha pagato l'imposta è stata 30 giorni fa, il proprietario deve pagare di nuovo.

1.b) Se il veicolo è una moto e l'ultima volta che il proprietario ha pagato l'imposta è stata 60 giorni fa, il proprietario deve pagare di nuovo.

In altre parole, se hai un'auto devi pagare ogni 30 giorni o se hai una moto devi pagare ogni 60 giorni.

Per ogni veicolo nel sistema, l'applicazione deve verificare tali regole e stampare quei veicoli (numero di targa e informazioni sul proprietario) che non li soddisfano.

Quello che voglio è:

2.a) Conforme ai principi SOLIDI (in particolare principio aperto / chiuso).

Quello che non voglio è (penso):

2.b) Un dominio anemico, quindi la logica aziendale dovrebbe andare all'interno delle entità aziendali.

Ho iniziato con questo:

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: il principio di apertura / chiusura è garantito; se vogliono la tassa sulla bicicletta che erediti dal Veicolo, allora sostituisci il metodo HasToPay e il gioco è fatto. Principio aperto / chiuso soddisfatto per semplice eredità.

I "problemi" sono:

3.a) Perché un veicolo deve sapere se deve pagare? Non è un problema di pubblica amministrazione? Se le regole della pubblica amministrazione per le tasse sulle auto cambiano, perché Car deve cambiare? Penso che dovresti chiedere: "allora perché hai inserito un metodo HasToPay all'interno di Vehicle?", E la risposta è: perché non voglio testare il tipo di veicolo (typeof) all'interno di PublicAdministration. Vedi un'alternativa migliore?

3.b) Forse il pagamento delle tasse è una preoccupazione del veicolo, o forse il mio progetto iniziale di Person-Vehicle-Car-Motorbike-PublicAdministration è semplicemente sbagliato, o ho bisogno di un filosofo e un migliore analista aziendale. Una soluzione potrebbe essere: spostare il metodo HasToPay in un'altra classe, possiamo chiamarlo TaxPayment. Quindi creiamo due classi derivate da TaxPayment; CarTaxPayment e MotorbikeTaxPayment. Quindi aggiungiamo una proprietà di pagamento astratta (di tipo TaxPayment) alla classe Vehicle e restituiamo l'istanza TaxPayment corretta dalle classi Car e Moto:

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; }
    }
}

E invochiamo il vecchio processo in questo modo:

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

Ma ora questo codice non verrà compilato perché non c'è alcun membro LastPaidTime all'interno di CarTaxPayment / MotorbikeTaxPayment / TaxPayment. Sto iniziando a vedere CarTaxPayment e MotorbikeTaxPayment più come "implementazioni di algoritmi" che come entità aziendali. In qualche modo ora quegli "algoritmi" hanno bisogno del valore LastPaidTime. Certo, possiamo trasmettere il valore al costruttore di TaxPayment, ma ciò violerebbe l'incapsulamento o le responsabilità del veicolo, o come lo chiamano quegli evangelisti della OOP, no?

3.c) Supponiamo di avere già un ObjectContext di Entity Framework (che utilizza i nostri oggetti di dominio; Persona, Veicolo, Auto, Moto, Amministrazione pubblica). Come faresti per ottenere un riferimento ObjectContext dal metodo PublicAdministration.GetAllVehicles e quindi implementare la funzionalità?

3.d) Se avessimo bisogno anche dello stesso riferimento ObjectContext per CarTaxPayment.HasToPay ma di uno diverso per MotorbikeTaxPayment.HasToPay, che si occupa di "iniettare" o come passeresti quei riferimenti alle classi di pagamento? In questo caso, evitando un dominio anemico (e quindi nessun oggetto di servizio), non ci conduce al disastro?

Quali sono le tue scelte di design per questo semplice scenario? Chiaramente gli esempi che ho mostrato sono "troppo complessi" per un compito facile, ma allo stesso tempo l'idea è di aderire ai principi SOLIDI e prevenire domini anemici (di cui è stato commissionato la distruzione).

Risposte:


15

Direi che né una persona né un veicolo dovrebbero sapere se un pagamento è dovuto.

riesaminare il dominio problematico

Se guardi al dominio problematico "persone che hanno automobili": per acquistare un'auto hai una sorta di contratto che trasferisce la proprietà da una persona all'altra, né l'auto né la persona cambiano in quel processo. Il tuo modello è completamente carente.

esperimento mentale

Supponendo che ci sia una coppia sposata, l'uomo possiede la macchina e sta pagando le tasse. Ora l'uomo muore, sua moglie eredita la macchina e pagherà le tasse. Tuttavia, i tuoi piatti rimarranno gli stessi (almeno nel mio paese lo faranno). È sempre la stessa macchina! Dovresti essere in grado di modellarlo nel tuo dominio.

=> Proprietà

Un'auto che non appartiene a nessuno è ancora un'auto, ma nessuno pagherà le tasse. In effetti, non stai pagando le tasse per l'auto, ma per il privilegio di possedere un'auto . Quindi la mia proposta sarebbe:

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());
    }

}

Questo è lungi dall'essere perfetto e probabilmente non è nemmeno lontanamente corretto il codice C #, ma spero che tu abbia l'idea (e qualcuno potrebbe ripulirlo). L'unico aspetto negativo è che probabilmente avresti bisogno di una fabbrica o qualcosa per creare il corretto CarOwnershiptra a Care aPerson


Penso che il veicolo stesso dovrebbe registrare le tasse pagate per esso e gli individui dovrebbero registrare le tasse pagate per tutti i loro veicoli. Un codice fiscale potrebbe essere scritto in modo tale che se l'imposta su un'auto viene pagata il 1 ° giugno e viene venduta il 10 giugno, il nuovo proprietario potrebbe diventare responsabile per l'imposta il 10 giugno, 1 luglio o 10 luglio (e anche se è attualmente un modo potrebbe cambiare). Se la regola dei 30 giorni è costante per l'auto anche attraverso i cambi di proprietà, ma le auto che non sono dovute da nessuno non possono pagare le tasse, suggerirei che quando viene acquistata un'auto che non era stata posseduta per 30 o più giorni, un pagamento delle tasse ...
supercat

... essere registrato da una persona fittizia "Credito d'imposta sui veicoli noto", in modo che al momento della vendita la responsabilità fiscale sia pari a zero o trenta giorni, indipendentemente da quanto tempo il veicolo non è stato posseduto.
supercat

4

Penso che in questo tipo di situazione, in cui si desidera che le regole aziendali esistano al di fuori degli oggetti target, il test per il tipo è più che accettabile.

Certamente, in molte situazioni dovresti usare una strategia patern e lasciare che gli oggetti gestiscano le cose da soli, ma non è sempre desiderabile.

Nel mondo reale, un impiegato dovrebbe esaminare il tipo di veicolo e cercare i suoi dati fiscali in base a quello. Perché il tuo software non dovrebbe seguire la stessa regola busienss?


Ok, supponiamo che tu mi abbia convinto e dal metodo GetVehiclesThatHaveToPay controlliamo il tipo di veicolo. Chi dovrebbe essere responsabile di LastPaidTime? LastPaidTime è una preoccupazione del veicolo o una pubblica amministrazione? Perché se si tratta di un veicolo, la logica aziendale non dovrebbe risiedere nel veicolo? Cosa puoi dirmi della domanda 3.c?

@DavidRobertJones - Idealmente, vorrei cercare l'ultimo pagamento nel registro Cronologia pagamenti, in base alla licenza del veicolo. Un pagamento fiscale è un problema fiscale, non un problema di veicolo.
Erik Funkenbusch,

5
@DavidRobertJones Penso che ti stai confondendo con la tua metafora. Quello che hai non è un veicolo reale che deve fare tutto ciò che fa un veicolo. Invece, hai chiamato, per semplicità, TaxableVehicle (o qualcosa di simile) un Veicolo. L'intero dominio è costituito da pagamenti fiscali. Non preoccuparti di cosa fanno i veicoli reali. E, se necessario, lascia cadere la metafora e ne esce una più appropriata.

3

Quello che non voglio è (penso):

2.b) Un dominio anemico, che significa logica aziendale all'interno delle entità aziendali.

Hai questo all'indietro. Un dominio anemico è quello in cui la logica è al di fuori delle entità aziendali. Ci sono molte ragioni per cui usare classi aggiuntive per implementare la logica è una cattiva idea; e solo un paio per il motivo per cui dovresti farlo.

Da qui il motivo per cui si chiama anemico: la classe non ha nulla in esso ..

Cosa farei:

  1. Cambia la tua classe di veicolo astratta per essere un'interfaccia.
  2. Aggiungi un'interfaccia ITaxPayment che i veicoli devono implementare. Chiedi a HasToPay di restare qui.
  3. Rimuovere le classi MotorbikeTaxPayment, CarTaxPayment, TaxPayment. Sono complicazioni / disordine inutili.
  4. Ogni tipo di veicolo (auto, bici, camper) dovrebbe implementare al minimo Veicolo e, se tassati, dovrebbe implementare anche ITaxPayment. In questo modo è possibile delineare tra quei veicoli che non sono tassati e quelli che ... Supponendo che questa situazione esista.
  5. Ogni tipo di veicolo dovrebbe implementare, entro i limiti della classe stessa, i metodi definiti nelle interfacce.
  6. Inoltre, aggiungi la data dell'ultimo pagamento all'interfaccia ITaxPayment.

Hai ragione. Inizialmente la domanda non era stata formulata in quel modo, ho eliminato una parte e il significato è cambiato. Ora è riparato. Grazie!

2

Perché un veicolo deve sapere se deve pagare? Non è un problema di pubblica amministrazione?

No. Da quanto ho capito, tutta la tua app riguarda le tasse. Quindi la preoccupazione se un veicolo debba essere tassato non è più una preoccupazione della Pubblica Amministrazione piuttosto che qualsiasi altra cosa nell'intero programma. Non c'è motivo di metterlo altrove se non qui.

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;
    }
}

Questi due frammenti di codice differiscono solo per la costante. Invece di sovrascrivere l'intera funzione HasToPay () sovrascrivere una int DaysBetweenPayments()funzione. Chiamalo invece di usare la costante. Se questo è tutto ciò su cui differiscono effettivamente, lascerei cadere le lezioni.

Suggerirei anche che Moto / Auto dovrebbe essere una proprietà di categoria del veicolo piuttosto che sottoclassi. Questo ti dà maggiore flessibilità. Ad esempio, semplifica la modifica della categoria di una moto o di un'auto. Naturalmente è improbabile che le biciclette si trasformino in auto nella vita reale. Ma sai che un veicolo verrà immesso in modo errato e dovrà essere cambiato in seguito.

Certo, possiamo trasmettere il valore al costruttore di TaxPayment, ma ciò violerebbe l'incapsulamento o le responsabilità del veicolo, o come lo chiamano quegli evangelisti della OOP, no?

Non proprio. Un oggetto che passa deliberatamente i suoi dati a un altro oggetto come parametro non è un grosso problema di incapsulamento. La vera preoccupazione è che altri oggetti raggiungono all'interno di questo oggetto per estrarre o manipolare i dati all'interno dell'oggetto.


1

Potresti essere sulla strada giusta. Il problema è, come hai notato, che non esiste alcun membro LastPaidTime in TaxPayment . È esattamente dove appartiene, anche se non ha bisogno di essere esposto pubblicamente a tutti:

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; }
} 

Ogni volta che ricevi un pagamento, crei una nuova istanza di ITaxPaymentsecondo le politiche correnti, che può avere regole completamente diverse da quella precedente.


Il fatto è che il pagamento dovuto dipende dall'ultima volta che il proprietario ha pagato (veicoli diversi, date di scadenza diverse). Come sa ITaxPayment quale è stata l'ultima volta che un veicolo (o proprietario) ha pagato per eseguire il calcolo?

1

Concordo con la risposta di @sebastiangeiger sul fatto che il problema riguarda le proprietà dei veicoli piuttosto che i veicoli. Sto per suggerire di usare la sua risposta ma con alcune modifiche. Vorrei rendere la Ownershipclasse una classe generica:

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; }
    }
}

E usa l'ereditarietà per specificare l'intervallo di pagamento per ciascun tipo di veicolo. Suggerisco anche di usare la TimeSpanclasse fortemente tipizzata invece di interi semplici:

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); }
    }
}

Ora il tuo codice attuale deve occuparsi solo di una classe - 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

Odio dirtelo, ma hai un dominio anemico , cioè una logica di business al di fuori degli oggetti. Se questo è ciò che stai facendo, va bene, ma dovresti leggere le ragioni per evitarlo.

Cerca di non modellare il dominio problematico, ma modella la soluzione. Ecco perché stai finendo con gerarchie di eredità parallele e più complessità del necessario.

Qualcosa del genere è tutto ciò che serve per quell'esempio:

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)
}

Se vuoi seguire SOLID, è fantastico, ma come si è detto lo zio Bob, sono solo principi ingegneristici . Non sono pensati per essere applicati in modo rigoroso, ma sono linee guida che dovrebbero essere considerate.


4
Penso che la tua soluzione non sia particolarmente scalabile. Dovresti aggiungere un nuovo booleano per ogni tipo di veicolo che aggiungi. Questa è una scelta sbagliata secondo me.
Erik Funkenbusch,

@Garrett Hall, mi è piaciuto che tu abbia rimosso la classe Person dal mio "dominio". In primo luogo non avrei tenuto quella lezione, quindi mi dispiace per quello. Ma isMotorBike non ha senso. Quale sarebbe l'implementazione?

1
@DavidRobertJones: sono d'accordo con Mystere, l'aggiunta di un isMotorBikemetodo non è una buona scelta di design OOP. È come sostituire il polimorfismo con un codice di tipo (anche se booleano).
Groo

@MystereMan, si potrebbe facilmente far cadere i bool e usare unenum Vehicles
radarbob il

+1. Cerca di non modellare il dominio problematico, ma modella la soluzione . Pithy . Detto memorabilmente. E i principi commentano. Vedo troppi tentativi di interpretazione letterale rigorosa ; senza senso.
radarbob,

0

Penso che tu abbia ragione che il periodo di pagamento non è di proprietà della vera auto / moto. Ma è un attributo della classe del veicolo.

Mentre potresti andare lungo la strada di avere un if / select sul Tipo di oggetto, questo non è molto scalabile. Se successivamente aggiungi camion e Segway e spingi bici, autobus e aereo (può sembrare improbabile, ma non lo sai mai con i servizi pubblici), la condizione diventa più grande. E se vuoi cambiare le regole di un camion, devi trovarlo in quell'elenco.

Quindi perché non renderlo, letteralmente, un attributo e usare la riflessione per estrarlo? Qualcosa come questo:

[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;
    }
}

Potresti anche scegliere una variabile statica, se è più comoda.

Gli svantaggi sono

  • che sarà leggermente più lento e presumo che tu debba processare molti veicoli. Se questo è un problema, allora potrei prendere una visione più pragmatica e attenermi alla proprietà astratta o all'approccio interfacciato. (Anche se, prenderei in considerazione anche la memorizzazione nella cache per tipo.)

  • Dovrai convalidare all'avvio che ogni sottoclasse del veicolo ha un attributo IPaymentRequiredCalculator (o consentire un valore predefinito), perché non vuoi che elabori 1000 auto, ma si blocca solo perché la moto non ha un PaymentPeriod.

Il lato positivo è che se in seguito ti imbatti in un mese di calendario o in un pagamento annuale, puoi semplicemente scrivere una nuova classe di attributi. Questa è la definizione stessa di OCP.


Il problema principale è che se si desidera modificare il numero di giorni tra i pagamenti per un veicolo, è necessario ricostruire e ridistribuire e se la frequenza di pagamento varia in base a una proprietà dell'entità (ad esempio, la dimensione del motore) sarà difficile trovare una soluzione elegante per quello.
Ed James,

@EdWoodcock: penso che il primo problema sia vero per la maggior parte delle soluzioni e non mi preoccuperebbe davvero. Se vogliono quel livello di flessibilità in un secondo momento, darei loro un'app DB. Sul secondo punto, non sono completamente d'accordo. A quel punto, aggiungi semplicemente il veicolo come argomento facoltativo a HasToPay () e lo ignori dove non ti serve.
pdr

Immagino che dipenda dai piani a lungo termine che hai per l'app, oltre ai requisiti particolari del client, sul fatto che valga la pena collegare un DB se non ne esiste già uno (tendo a supporre che praticamente tutto il software sia DB -driven, che so non è sempre il caso). Tuttavia, sul secondo punto, l'importante qualificatore è elegante ; Non direi che l'aggiunta di un parametro aggiuntivo a un metodo su un attributo che quindi prende un'istanza della classe a cui è associato l'attributo è una soluzione particolarmente elegante. Ma, ancora una volta, dipende dalle esigenze del cliente.
Ed James,

@EdWoodcock: In realtà, stavo solo pensando a questo e l'argomento lastPaid dovrà già essere una proprietà di Vehicle. Con il tuo commento in mente, potrebbe valere la pena sostituire quell'argomento ora, piuttosto che dopo - verrà modificato.
pdr,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.