Domanda

In applying the Data Mapper pattern, the model (Domain Model in my case) is responsible for business logic where possible, rather than the mapper that saves the entity to the database.

Does it seem reasonable to build a separate business logic validator for processing user-provided data outside of the model?

An example is below, in PHP syntax.

Let's say we have an entity $person. Let's say that that entity has a property surname which can not be empty when saved.

The user has entered an illegal empty value for surname. Since the model is responsible for encapsulating business logic, I'd expect $person->surname = $surname; to somehow say that the operation was not successful when the user-entered $surname is an empty string.

It seems to me that $person should throw an exception if we attempt to fill one of its properties with an illegal value.

However, from what I've read on exceptions "A user entering 'bad' input is not an exception: it's to be expected." The implication is to not rely on exceptions to validate user data.

How would you suggest approaching this problem, with the balance between letting the Domain Model define business logic, yet not relying on exceptions being thrown by that Domain Model when filling it with user-entered data?

È stato utile?

Soluzione

A Domain Model is not necessarily an object that can be directly translated to a database row. Your Person example does fit this description, and I like to call such an object an Entity (adopted from the Doctrine 2 ORM). But, like Martin Fowler describes, a Domain Model is any object that incorporates both behavior and data.

a strict solution

Here's a quite strict solution to the problem you describe:

Say your Person Domain Model (or Entity) must have a first name and last name, and optionally a maiden name. These must be strings, but for simplicity may contain any character. You want to enforce that whenever such a Person exists, these prerequisites are met. The class would look like this:

class Person
{
    /**
     * @var string
     */
    protected $firstname;

    /**
     * @var string
     */
    protected $lastname;

    /**
     * @var string|null
     */
    protected $maidenname;

    /**
     * @param  string      $firstname
     * @param  string      $lastname
     * @param  string|null $maidenname
     */
    public function __construct($firstname, $lastname, $maidenname = null)
    {
        $this->setFirstname($firstname);
        $this->setLastname($lastname);
        $this->setMaidenname($maidenname);
    }

    /**
     * @param string $firstname
     */
    public function setFirstname($firstname)
    {
        if (!is_string($firstname)) {
            throw new InvalidArgumentException('Must be a string');
        }

        $this->firstname = $firstname;
    }

    /**
     * @return string
     */
    public function getFirstname()
    {
        return $this->firstname;
    }

    /**
     * @param string $lastname
     */
    public function setLastname($lastname)
    {
        if (!is_string($lastname)) {
            throw new InvalidArgumentException('Must be a string');
        }

        $this->lastname = $lastname;
    }

    /**
     * @return string
     */
    public function getLastname()
    {
        return $this->lastname;
    }

    /**
     * @param string|null $maidenname
     */
    public function setMaidenname($maidenname)
    {
        if (!is_string($maidenname) or !is_null($maidenname)) {
            throw new InvalidArgumentException('Must be a string or null');
        }

        $this->maidenname = $maidenname;
    }

    /**
     * @return string|null
     */
    public function getMaidenname()
    {
        return $this->maidenname;
    }
}

As you can see there is no way (disregarding Reflection) that you can instantiate a Person object without having the prerequisites met. This is a good thing, because whenever you encounter a Person object, you can be a 100% sure about what kind of data you are dealing with.

Now you need a second Domain Model to handle user input, lets call it PersonForm (because it often represents a form being filled out on a website). It has the same properties as Person, but blindly accepts any kind of data. It will also have a list of validation rules, a method like isValid() that uses those rules to validate the data, and a method to fetch any violations. I'll leave the definition of the class to your imagination :)

Last you need a Controller (or Service) to tie these together. Here's some pseudo-code:

class PersonController
{
    /**
     * @param Request      $request
     * @param PersonMapper $mapper
     * @param ViewRenderer $view
     */
    public function createAction($request, $mapper, $view)
    {
        if ($request->isPost()) {
            $data = $request->getPostData();

            $personForm = new PersonForm();
            $personForm->setData($data);

            if ($personForm->isValid()) {
                $person = new Person(
                    $personForm->getFirstname(),
                    $personForm->getLastname(),
                    $personForm->getMaidenname()
                );

                $mapper->insert($person);

                // redirect
            } else {
                $view->setErrors($personForm->getViolations());
                $view->setData($data);
            }
        }

        $view->render('create/add');
    }
}

As you can see the PersonForm is used to intercept and validate user input. And only if that input is valid a Person is created and saved in the database.

business rules

This does mean that certain business logic will be duplicated:

In Person you'll want to enforce business rules, but it can simple throw an exception when something is off.

In PersonForm you'll have validators that apply the same rules to prevent invalid user input from reaching Person. But here those validators can be more advanced. Think off things like human error messages, breaking on the first rule, etc. You can also apply filters that change the input slightly (like lowercasing a username for example).

In other words: Person will enforce business rules on a low level, while PersonForm is more about handling user input.

more convenient

A less strict approach, but maybe more convenient:

Limit the validation done in Person to enforce required properties, and enforce the type of properties (string, int, etc). No more then that.

You can also have a list of constraints in Person. These are the business rules, but without actual validation code. So it's just a bit of configuration.

Have a Validator service that is capable of receiving data along with a list of constraints. It should be able to validate that data according to the constraints. You'll probably want a small validator class for each type of constraint. (Have a look at the Symfony 2 validator component).

PersonForm can have the Validator service injected, so it can use that service to validate the user input.

Lastly, have a PersonManager service that's responsible for any actions you want to perform on a Person (like create/update/delete, and maybe things like register/activate/etc). The PersonManager will need the PersonMapper as dependency.

When you need to create a Person, you call something like $personManager->create($userInput); That call will create a PersonForm, validate the data, create a Person (when the data is valid), and persist the Person using the PersonMapper.

The key here is this:

You could draw a circle around all these classes and call it your "Person domain" (DDD). And the interface (entry point) to that domain is the PersonManager service. Every action you want to perform on a Person must go through PersonManager.

If you stick to that in your application, you should be safe regarding to ensuring business rules :)

Altri suggerimenti

I think the statement "A user entering 'bad' input is not an exception: it's to be expected." is debatable...

But if you don't want to throw an exception, why don't you create an isValid(), or getValidationErrors() method?

You can then throw an exception, if someone tries to save an invalid entity to the database.

Your domain requires that when creating a person, you will provide a first name and a surname. The way I normally approach this is by validating the input model, an input model might look like;

class PersonInput
{
  var $firstName;
  var $surname;

  public function isValid() { 
    return isset($this->firstName) && isset($this->surname);
  }
}

This is really a guard, you can put these rules in your client side code as well to try and prevent this scenario, or you can you return from your post with an invalid person message. I don't see this as an exception, more along the lines of "to be expected" which is why I write the guard code. Your entry into your domain now might look like;

public function createPerson(PersonInput $input) { 
  if( $input->isValid()) {
     $model->createPerson( $input->firstName, $input->surname);

     return 'success';
  } else {
     return 'person must comtain a valid first name and surname';
  }
}

This is just my opinion, and how I go about keeping my validation logic away from the domain logic.

I think your design in which the $person->surname = ''; should raise an error or exception could be simplified.

Return the error once

You dont want to catch errors all the time when assigning each value, you want a simple one-stop solution like $person->Valididate() that looks at the current values. Then when you'd call a ->Save() function, it could automatically call ->Validate() first and simply return False.

Return the error details

But returning False, or even an errorcode is often not sufficient: you want the 'who? why?' details. So lets use a class instance to contain the details, i call it ItemFieldErrors. Its passed to Save() and only looked at when Save() returns False.

public function Validate(&$itemFieldErrors = NULL, $aItem = NULL);

Try this complete ItemFieldErrors implementation. An array would suffice, but i found this more structured, versatile and self-documenting. You could always pass and parse the error details more intelligently anywhere/way you like, but often (if not always..) just outputting the asText() summary would do.

/**
 * Allows a model to log absent/invalid fields for display to user.
 * Can output string like "Birthdate is invalid, Surname is missing"
 * 
 * Pass this to your Validate() model function.
 */
class ItemFieldErrors
{
  const FIELDERROR_MISSING = 1;
  const FIELDERROR_INVALID = 2;

  protected $itemFieldErrors = array();

  function __construct()
  {
    $this->Clear();
  }

  public function AddErrorMissing($fieldName)
  {
    $this->itemFieldErrors[] = array($fieldName, ItemFieldErrors::FIELDERROR_MISSING);
  }

  public function AddErrorInvalid($fieldName)
  {
    $this->itemFieldErrors[] = array($fieldName, ItemFieldErrors::FIELDERROR_INVALID);
  }

  public function ErrorCount()
  {
    $count = 0;
    foreach ($this->itemFieldErrors as $error) {
      $count++;
    }
    unset($error);
    return $count;
  }

  public function Clear()
  {
    $this->itemFieldErrors = array();
  }

  /**
   * Generate a human readable string to display to user.
   * @return string
   */
  public function AsText()
  {
    $s = '';
    $comma = '';
    foreach($this->itemFieldErrors as $error) {
      switch ($error[1]) {
        case ItemFieldErrors::FIELDERROR_MISSING:
          $s .= $comma . sprintf(qtt("'%s' is absent"), $error[0]);
          break;
        case ItemFieldErrors::FIELDERROR_INVALID:
          $s .= $comma . sprintf(qtt("'%s' is invalid"), $error[0]);
          break;
        default:
          $s .= $comma . sprintf(qtt("'%s' has unforseen issue"), $error[0]);
          break;
      }
      $comma = ', ';
    }
    unset($error);
    return $s;
  }
}

Then ofcourse there is $person->Save() that needs to receive it so it can pass it through to Validate(). In my code, whenever i 'load' data from the user (form submission) the same Validate() is called already, not only when saving.

The model, would do this:

class PersonModel extends BaseModel {

  public $item = array();

  public function Validate(&$itemFieldErrors = NULL, $aItem = NULL) {
    // Prerequisites
    if ($itemFieldErrors === NULL) { $itemFieldErrors = new ItemFieldErrors(); }
    if ($aItem === NULL) { $aItem = $this->item; }

    // Validate
    if (trim($aItem['name'])=='')          { $itemFieldErrors->AddErrorMissing('name'); }
    if (trim($aItem['surname'])=='')       { $itemFieldErrors->AddErrorMissing('surname'); }
    if (!isValidDate($aItem['birthdate'])) { $itemFieldErrors->AddErrorInvalid('birthdate'); }

    return ($itemFieldErrors->ErrorCount() == 0);
  }

  public function Load()..
  public function Save()..
}

This simple model would hold all data in $item, so it simply exposes fields as $person->item['surname'].

Autorizzato sotto: CC-BY-SA insieme a attribuzione
Non affiliato a StackOverflow
scroll top