48

Polymorphism and separating logic from entities · ondrejbouda/knowledge Wiki · G...

 6 years ago
source link: https://github.com/ondrejbouda/knowledge/wiki/Polymorphism-and-separating-logic-from-entities
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Disclaimer

This started as a set of notes for myself, so the examples are not refined. But I think the sketches of solutions should be enough for people that encountered similar problems.

There are no "great ideas", and even if, they are not mine. It is just a list of solutions that I know about and use in our codebase. There are many similar issues that I ran into during the past year during my work and many different solutions, that lead me to writing this.

Context, preconditions

  • an architecture, which places complex logic into services, not into entities (need not be strictly anemic model, just "reasonable")
  • entities cannot or should not have dependecies and therefore cannot use services (typically Doctrine entities)

Problem

  • I have a polymorphic set of entity types, e.g.:
class MaxUsagesByUserPerMonth implements Condition;
class UserHasARole implements Condition;
  • I need to run different complex operations for different types of entities
  • Usually I need to process a batch, a collection of such entities

Goal: how to separate the logic from entity and

  • not violate open/closed principle
  • not violate single responsiblility principle
  • not violate encapsulation
  • not impair code readibility and navigation through codebase too much

Solutions

1. Explicitly resolving specialized service depending on type

if ($condition instanceof MaxUsagesByUserPerMonth) {
    $usagesPerMonthValidator->validate($condition);
} elseif ($condition instanceof UserHasARole) {
    $userRoleValidator->validate($condition);
}

Pros:

  • easy, straightforward

Cons:

  • will grow over time
  • will be repeated in various places
  • violates SRP - does more things, has more reasons to change
  • violates open/closed principle - must be changed when new type is added, can easily be broken

It is best to keep logic choosing which service to use at highest level possible. Ideally there should be only one place with service choosing logic, e.g. an abstract factory.

2. Specialized services and resolving handler

  • a more formalized version of previous solution
  • specialized handler service for each type
  • resolver that chooses correct specialized service
  • when new type is added, new service is added and registered in resolver - can be done in container configuration
interface Validator {
    function supportsTypes() : array;
    function validate(Condition $condition);
}

class UsagesPerMonthValidator implements Validator {
    function supportsTypes() : array {
        return [MaxUsagesByUserPerMonth::class];
    }
    function validate(Condition $condition) {
        $if (!($condition instanceof MaxUsagesByUserPerMonth)) {
            throw new \LogicException('Cannot validate this condition...');
        } 
        // some logic
    }
}

class ResolvingValidator {
    /** @var Validator[] **/
    $validatorsByType;
    function validate(Condition $condition) {
        $type = get_class($condition);
        $this->validatorsByType[$type]->validate($condition);
    }
}

Pros:

  • does not violate any aforementioned principles

Cons:

  • there's no guarantee that every type has a handler
  • not strict type security: usual implementation has interface that accepts general type and casts it to specialized, as seen in example above

It is best to keep resolving handler at highest level possible. It is even possible to wire whole service trees separately for different types, thus needing only one resolver. Navigation through codebase is seriously impaired then, though.

3. Inject dependency into entity via method

This is really a solution that solves a broader problem (entity using a service). It is usually not good with polymorphism, because you typically need different services for different subtypes.

interface Condition {
    function validateWith(SomeService $service);
}

class MaxUsagesByUserPerMonth implements Condition {
    function validateWith(SomeService $service) {
        $service->doWhatINeedYouToDo();
    }
}

Pros:

  • entity is responsible for choosing behaviour
  • does not violate any principles

Cons:

  • subjectively a little "dirty" - circumvents the "no dependencies in entity" rule
  • some types may not need the injected service, but must adhere to interface, thus having a misleading method signature

This is good for choosing a strategy. E.g. User entity gets a PasswordEncryptionStrategy as a method argument when asked to verify a password.

4. Visitor pattern

  • a more formalized version of previous solution
  • entity implements visit method, can be visited by visitor
  • common Visitor interface, entity method can choose which method of visitor to use
  • visitor doesn't know about different entity types
  • there can be more Visitors doing different things
interface ConditionVisitor {
    function visitMaxUsagesByUserPerMonthCondition(MaxUsagesByUserPerMonthCondition $condition);
    function visitUserHasARoleCondition(UserHasARoleCondition $condition);
}

class PrintingConditionVisitor implements ConditionVisitor {
    function visitMaxUsagesByUserPerMonthCondition(MaxUsagesByUserPerMonthCondition $condition) {
        // echo some custom condition attributes
    }
    function visitUserHasARoleCondition(UserHasARoleCondition $condition) {
        // echo some custom condition attributes
    }
}

interface Condition {
    function accept(ConditionVisitor $visitor);
}

class MaxUsagesByUserPerMonthCondition implements Condition {
    function accept(ConditionVisitor $visitor) {
        $visitor->visitMaxUsagesByUserPerMonthCondition($this);
    }
}

Cons:

  • a little convulted
  • it is difficult to return a value, it cannot be declared in entity method signature
  • it is difficult to declare throwing an exception

Pros:

  • adheres to all principles (partially, some/all visitors need to change when new entity type is added and cannot use existing methods; however - the entity is responsible for choosing the correct method, so it cannot be ommited when implementing new entity type)
  • type safe - entity must choose existing method
  • cannot add new type without choosing existing functionality or implementing a new one
  • visitor is an interface, more implementations can exist and only one place (entity method) chooses behaviour

This solution is good for outputting or collecting data from each of polymorphic type. Implementation is much simpler in languages with method overloading, e.g. Java.

Conclusion

Every solution has its pros and cons. The first one usually gets used initially, but should be exchanged for more robust one eventually, as soon as need arises to alter or extend existing code.

The more robust solutions (2 and 4) introduce significant cognitive load on reader of code, because it is not straightforward which class/method gets used in the end. It is hidden behind interface and may be configured in container. On the other hand, ideally the implementation details of different services should not be relevant.

Related concepts

https://en.wikipedia.org/wiki/Double_dispatch

Sources


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK