2

I’m creating an authentication / login system using Slim 3 PHP on the back-end and Angular on the front-end and I’m trying to understand the ‘domain object’ and ‘data mapper’ part of a model layer within an MVC structure. I’ve read a lot of useful answers on various questions such as this, from which I understand the model should be comprised of ‘domain objects’, ‘data mappers’ and ‘services’.

However I’m not exactly sure what how this should be structured in the context of a user being able to register and log in to a website.

From my understanding I could have a user 'domain object' that has properties such as username and password. It could also have methods such as register or log in to represent business logic.

Would I then have a service class that creates a new instance of a user object, in which I would pass the form data into the object? So now my user object instance would have set username and password values?

Now i'm not sure how this objects property data would be inserted into the database. Would I use the user objects register method to insert the data into the database by passing in the username and password as parameters?

Apparently the service should be where the domain object and the data mapper interact, but i'm not sure how this would work if the register method is in the user domain object.

I was hoping someone could show me some code examples of what should be in the service class and how the interaction between the domain object and data mapper might work in the context of a user registering and logging in.

Note I don't want to use any frameworks, I want to try and implement a proper MVC structure manually as I feel i'd learn more.

So far I have this structure for registering a user:

I have an AuthenticationController with the method registerUser to allow a user to create an account:

 class AuthenticationController
{
    protected $authenticationService;

    public function __construct(AuthenticationService $authenticationService)
    {
        $this->authenticationService = $authenticationService;
    }

    public function registerUser($request, $response)
    {
        $this->authenticationService->registerUser($request, $response);
    }
}

I then have the AuthenticationService class with the registerUser method:

class AuthenticationService
{
    protected $database;

    public function __construct(PDO $database)
    {
        $this->database = $database;
    }

    public function registerUser ($request, $response)
    {
        $strings = $request→getParsedBody(); // will be sanitised / validated later
        $username = $strings['username'];
        $password = $strings['password'];
        $email = "temp random email";

        $stmt = $this->database->prepare("INSERT INTO users (email, username, password) values (:email, :username, :password)");
        $stmt->bindParam(':email', $email);
        $stmt->bindParam(':username', $username);
        $stmt->bindParam(':password', $password);
        $stmt->execute();
    }
}

Later on I intend to put the SQL into an AuthenticationRepository and the PDO logic into it’s own class. This AuthenticationService method will also make sure the user details are sanitised using PHP’s built in functions.

I’m not sure if the proposed PDO database class or AuthenticationRepository would count as a data mapper or not.

SneakyShrike
  • 723
  • 1
  • 10
  • 31

1 Answers1

4
  • The registration would be performed by the service.
  • The service could "directly" use a data mapper, in order to "transfer" the entity to/from the database. Though, additionally, a repository can be implemented. The service would see it and communicate with it as with a collection of one or more entities.
  • Since a service is part of the model layer (domain model), it should know nothing about any request or response objects. The controller should extract the needed values from the request and pass them as arguments to the service methods. A response can be sent back by the controller, or the view, depending on which MVC variation you are trying to implement.
  • You say "I intend to put the [...] PDO logic into it's own class". You really don't need to implement a wrapper for the PDO extension.

Here a registration example. I didn't test it at all. For more details see the resources list at the end of this answer. Maybe begin with the last one, which - I just realized - is the answer to a question of yours.

Used file system structure:

a) Extended "MyApp/UI":

Structure: MyApp/UI

b) Extended "MyApp/Domain":

Structure: MyApp/Domain


The controller:

<?php

namespace MyApp\UI\Web\Controller\Users;

use Psr\Http\Message\ServerRequestInterface;
use MyApp\Domain\Model\Users\Exception\InvalidData;
use MyApp\Domain\Service\Users\Exception\FailedRegistration;
use MyApp\Domain\Service\Users\Registration as RegistrationService;

class Registration {

    private $registration;

    public function __construct(RegistrationService $registration) {
        $this->registration = $registration;
    }

    public function register(ServerRequestInterface $request) {
        $username = $request->getParsedBody()['username'];
        $password = $request->getParsedBody()['password'];
        $email = $request->getParsedBody()['email'];

        try {
            $user = $this->registration->register($username, $password, $email);
        } catch (InvalidData $exc) {
            // Write the exception message to a flash messenger, for example, 
            // in order to be read and displayed by the specific view component.
            var_dump($exc->getMessage());
        } catch (FailedRegistration $exc) {
            // Write the exception message to the flash messenger.
            var_dump($exc->getMessage());
        }

        // In the view component, if no exception messages are found in the flash messenger, display a success message.
        var_dump('Successfully registered.');
    }

}

The service:

<?php

namespace MyApp\Domain\Service\Users;

use MyApp\Domain\Model\Users\User;
use MyApp\Domain\Model\Users\Email;
use MyApp\Domain\Model\Users\Password;
use MyApp\Domain\Service\Users\Exception\UserExists;
use MyApp\Domain\Model\Users\UserCollection as UserCollectionInterface;

class Registration {

    /**
     * User collection, e.g. user repository.
     * 
     * @var UserCollectionInterface
     */
    private $userCollection;

    public function __construct(UserCollectionInterface $userCollection) {
        $this->userCollection = $userCollection;
    }

    /**
     * Register user.
     * 
     * @param string $username Username.
     * @param string $password Password.
     * @param string $email Email.
     * @return User User.
     */
    public function register(string $username, string $password, string $email) {
        $user = $this->createUser($username, $password, $email);

        return $this->storeUser($user);
    }

    /**
     * Create user.
     * 
     * @param string $username Username.
     * @param string $password Password.
     * @param string $email Email.
     * @return User User.
     */
    private function createUser(string $username, string $password, string $email) {
        // Create the object values (containing specific validation).
        $email = new Email($email);
        $password = new Password($password);

        // Create the entity (e.g. the domain object).
        $user = new User();

        $user->setUsername($username);
        $user->setEmail($email);
        $user->setPassword($password);

        return $user;
    }

    /**
     * Store user.
     * 
     * @param User $user User.
     * @return User User.
     */
    private function storeUser(User $user) {
        // Check if user already exists.
        if ($this->userCollection->exists($user)) {
            throw new UserExists();
        }

        return $this->userCollection->store($user);
    }

}

The exception thrown when trying to register an already existing user:

<?php

namespace MyApp\Domain\Service\Users\Exception;

use MyApp\Domain\Service\Users\Exception\FailedRegistration;

class UserExists extends FailedRegistration {

    public function __construct(\Exception $previous = null) {
        $message = 'User already exists.';
        $code = 123;

        parent::__construct($message, $code, $previous);
    }

}

<?php

namespace MyApp\Domain\Service\Users\Exception;

abstract class FailedRegistration extends \Exception {

    public function __construct(string $message, int $code = 0, \Exception $previous = null) {
        $message = 'Registration failed: ' . $message;

        parent::__construct($message, $code, $previous);
    }

}

The domain object (entity):

<?php

namespace MyApp\Domain\Model\Users;

use MyApp\Domain\Model\Users\Email;
use MyApp\Domain\Model\Users\Password;

/**
 * User entity (e.g. domain object).
 */
class User {

    private $id;
    private $username;
    private $email;
    private $password;

    public function getId() {
        return $this->id;
    }

    public function setId(int id) {
        $this->id = $id;
        return $this;
    }

    public function getUsername() {
        return $this->username;
    }

    public function setUsername(string $username) {
        $this->username = $username;
        return $this;
    }

    public function getEmail() {
        return $this->email;
    }

    public function setEmail(Email $email) {
        $this->email = $email;
        return $this;
    }

    public function getPassword() {
        return $this->password;
    }

    public function setPassword(Password $password) {
        $this->password = $password;
        return $this;
    }

}

The value objects used by the entity:

<?php

namespace MyApp\Domain\Model\Users;

use MyApp\Domain\Model\Users\Exception\InvalidEmail;

/**
 * Email object value.
 */
class Email {

    private $email;

    public function __construct(string $email) {
        if (!$this->isValid($email)) {
            throw new InvalidEmail();
        }

        $this->email = $email;
    }

    private function isValid(string $email) {
        return (isEmpty($email) || !isWellFormed($email)) ? false : true;
    }

    private function isEmpty(string $email) {
        return empty($email) ? true : false;
    }

    private function isWellFormed(string $email) {
        return !filter_var($email, FILTER_VALIDATE_EMAIL) ? false : true;
    }

    public function __toString() {
        return $this->email;
    }

}

<?php

namespace MyApp\Domain\Model\Users;

use MyApp\Domain\Model\Users\Exception\InvalidPassword;

/**
 * Password object value.
 */
class Password {

    private const MIN_LENGTH = 8;

    private $password;


    public function __construct(string $password) {
        if (!$this->isValid($password)) {
            throw new InvalidPassword();
        }

        $this->password = $password;
    }

    private function isValid(string $password) {
        return (isEmpty($password) || isTooShort($password)) ? false : true;
    }

    private function isEmpty(string $password) {
        return empty($password) ? true : false;
    }

    private function isTooShort(string $password) {
        return strlen($password) < self::MIN_LENGTH ? true : false;
    }

    public function __toString() {
        return $this->password;
    }

}

The exceptions thrown by the value objects:

<?php

namespace MyApp\Domain\Model\Users\Exception;

use MyApp\Domain\Model\Users\Exception\InvalidData;

class InvalidEmail extends InvalidData {

    public function __construct(\Exception $previous = null) {
        $message = 'The email address is not valid.';
        $code = 123402;

        parent::__construct($message, $code, $previous);
    }

}

<?php

namespace MyApp\Domain\Model\Users\Exception;

use MyApp\Domain\Model\Users\Exception\InvalidData;

class InvalidPassword extends InvalidData {

    public function __construct(\Exception $previous = null) {
        $message = 'The password is not valid.';
        $code = 123401;

        parent::__construct($message, $code, $previous);
    }

}

<?php

namespace MyApp\Domain\Model\Users\Exception;

abstract class InvalidData extends \LogicException {

    public function __construct(string $message, int $code = 0, \Exception $previous = null) {
        $message = 'Invalid data: ' . $message;

        parent::__construct($message, $code, $previous);
    }

}

The repository interface:

<?php

namespace MyApp\Domain\Model\Users;

use MyApp\Domain\Model\Users\User;

/**
 * User collection, e.g. user repository.
 */
interface UserCollection {

    /**
     * Find a user by id.
     * 
     * @param int $id User id.
     * @return User|null User.
     */
    public function findById(int $id);

    /**
     * Find all users.
     * 
     * @return User[] User list.
     */
    public function findAll();

    /**
     * Check if the given user exists.
     * 
     * @param User $user User
     * @return bool True if user exists, false otherwise.
     */
    public function exists(User $user);

    /**
     * Store a user.
     * 
     * @param User $user User
     * @return User User.
     */
    public function store(User $user);

}

The repository:

<?php

namespace MyApp\Domain\Infrastructure\Repository\Users;

use MyApp\Domain\Model\Users\User;
use MyApp\Domain\Infrastructure\Mapper\Users\UserMapper;
use MyApp\Domain\Model\Users\UserCollection as UserCollectionInterface;

/**
 * User collection, e.g. user repository.
 */
class UserCollection implements UserCollectionInterface {

    private $userMapper;

    public function __construct(UserMapper $userMapper) {
        $this->userMapper = $userMapper;
    }

    /**
     * Find a user by id.
     * 
     * @param int $id User id.
     * @return User|null User.
     */
    public function findById(int $id) {
        return $this->userMapper->fetchUserById($id);
    }

    /**
     * Find all users.
     * 
     * @return User[] User list.
     */
    public function findAll() {
        return $this->userMapper->fetchAllUsers();
    }

    /**
     * Check if the given user exists.
     * 
     * @param User $user User
     * @return bool True if user exists, false otherwise.
     */
    public function exists(User $user) {
        return $this->userMapper->userExists($user);
    }

    /**
     * Store a user.
     * 
     * @param User $user User
     * @return User User.
     */
    public function store(User $user) {
        return $this->userMapper->saveUser($user);
    }

}

The data mapper interface:

<?php

namespace MyApp\Domain\Infrastructure\Mapper\Users;

use MyApp\Domain\Model\Users\User;

/**
 * User mapper.
 */
interface UserMapper {

    /**
     * Fetch a user by id.
     * 
     * @param int $id User id.
     * @return User|null User.
     */
    public function fetchUserById(int $id);

    /**
     * Fetch all users.
     * 
     * @return User[] User list.
     */
    public function fetchAllUsers();

    /**
     * Check if the given user exists.
     * 
     * @param User $user User.
     * @return bool True if the user exists, false otherwise.
     */
    public function userExists(User $user);

    /**
     * Save a user.
     * 
     * @param User $user User.
     * @return User User.
     */
    public function saveUser(User $user);
}

The data mapper:

<?php

namespace MyApp\Domain\Infrastructure\Mapper\Users;

use PDO;
use MyApp\Domain\Model\Users\User;
use MyApp\Domain\Model\Users\Email;
use MyApp\Domain\Model\Users\Password;
use MyApp\Domain\Infrastructure\Mapper\Users\UserMapper;

/**
 * PDO user mapper.
 */
class PdoUserMapper implements UserMapper {

    /**
     * Database connection.
     * 
     * @var PDO
     */
    private $connection;

    public function __construct(PDO $connection) {
        $this->connection = $connection;
    }

    /**
     * Fetch a user by id.
     * 
     * Note: PDOStatement::fetch returns FALSE if no record is found.
     * 
     * @param int $id User id.
     * @return User|null User.
     */
    public function fetchUserById(int $id) {
        $sql = 'SELECT * FROM users WHERE id = :id LIMIT 1';

        $statement = $this->connection->prepare($sql);
        $statement->execute([
            'id' => $id,
        ]);

        $record = $statement->fetch(PDO::FETCH_ASSOC);

        return ($record === false) ? null : $this->convertRecordToUser($record);
    }

    /**
     * Fetch all users.
     * 
     * @return User[] User list.
     */
    public function fetchAllUsers() {
        $sql = 'SELECT * FROM users';

        $statement = $this->connection->prepare($sql);
        $statement->execute();

        $recordset = $statement->fetchAll(PDO::FETCH_ASSOC);

        return $this->convertRecordsetToUserList($recordset);
    }

    /**
     * Check if the given user exists.
     * 
     * Note: PDOStatement::fetch returns FALSE if no record is found.
     * 
     * @param User $user User.
     * @return bool True if the user exists, false otherwise.
     */
    public function userExists(User $user) {
        $sql = 'SELECT COUNT(*) as cnt FROM users WHERE username = :username';

        $statement = $this->connection->prepare($sql);
        $statement->execute([
            ':username' => $user->getUsername(),
        ]);

        $record = $statement->fetch(PDO::FETCH_ASSOC);

        return ($record['cnt'] > 0) ? true : false;
    }

    /**
     * Save a user.
     * 
     * @param User $user User.
     * @return User User.
     */
    public function saveUser(User $user) {
        $id = $user->getId();

        if (!isset($id)) {
            return $this->insertUser($user);
        }

        return $this->updateUser($user);
    }

    /**
     * Insert a user.
     * 
     * @param User $user User.
     * @return User User.
     */
    private function insertUser(User $user) {
        $sql = 'INSERT INTO users (
                    username,
                    password,
                    email
                ) VALUES (
                    :username,
                    :password,
                    :email
                )';

        $statement = $this->connection->prepare($sql);
        $statement->execute([
            ':username' => $user->getUsername(),
            ':password' => (string) $user->getPassword(),
            ':email' => (string) $user->getEmail(),
        ]);

        $user->setId($this->connection->lastInsertId());

        return $user;
    }

    /**
     * Update a user.
     * 
     * @param User $user User.
     * @return User User.
     */
    private function updateUser(User $user) {
        $sql = 'UPDATE users 
                SET 
                    username = :username,
                    password = :password,
                    email = :email 
                WHERE id = :id';

        $statement = $this->connection->prepare($sql);
        $statement->execute([
            ':id' => $user->getId(),
            ':username' => $user->getUsername(),
            ':password' => (string) $user->getPassword(),
            ':email' => (string) $user->getEmail(),
        ]);

        return $user;
    }

    /**
     * Convert a record to a user.
     * 
     * @param array $record Record data.
     * @return User User.
     */
    private function convertRecordToUser(array $record) {
        $user = $this->createUser(
                    $record['id'],
                    $record['username'],
                    $record['password'],
                    $record['email']
                );

        return $user;
    }

    /**
     * Convert a recordset to a list of users.
     * 
     * @param array $recordset Recordset data.
     * @return User[] User list.
     */
    private function convertRecordsetToUserList(array $recordset) {
        $users = [];

        foreach ($recordset as $record) {
            $users[] = $this->convertRecordToUser($record);
        }

        return $users;
    }

    /**
     * Create user.
     *
     * @param int $id User id.
     * @param string $username Username.
     * @param string $password Password.
     * @param string $email Email.
     * @return User User.
     */
    private function createUser(int $id, string $username, string $password, string $email) {
        $user = new User();

        $user
            ->setId($id)
            ->setUsername($username)
            ->setPassword(new Password($password))
            ->setEmail(new Email($email))
        ;

        return $user;
    }

}

Resources:

PajuranCodes
  • 303
  • 3
  • 12
  • 43
  • Thank you for doing this, this is incredibly helpful. I am wondering though, is it viable to move the validation to the frontend that uses angular? Input validation seems like it would be a frontend thing. I'll keep password hashing and user input santation on the api side though. – SneakyShrike Nov 02 '19 at 23:28
  • You are welcome. Personally, I would try to validate the user input on both frontend and backend. I am not sure what you understand under user input sanitation. – PajuranCodes Nov 03 '19 at 00:08
  • Well I understand that sanitation strips out unwanted characters from inputs, I use PHP's 'FILTER_SANITIZE_EMAIL' and 'FILTER_SANITIZE_STRING' for this. But in regards to validation being on both front and back end, should they both say make sure an email is properly formatted in the same manner? It just seems a bit pointless having it on both. Could it be that if they break the validation on the front end there is still a barrier on the back end as a second line of defense? – SneakyShrike Nov 03 '19 at 16:58
  • My bad. Let me rectify my previous comment: _"I would try to validate the user input on both frontend and backend, but almost never only on the client-side"._ By using "almost", I have in mind, that you are, maybe, developing an intranet for the company for which you are working, and you know for sure, that the users are trustworthy and will always have the client-side libraries enabled. Though, even this situation can not be fully guaranteed. – PajuranCodes Nov 03 '19 at 18:52
  • With _"...there is still a barrier on the back end"_ you correctly recognised the reason of using both. And, indeed, _"It just seems a bit pointless having it on both"_. It would be pointless to use a specific client-side library for specific input data, but another server-side library for the server-side validation of the same data. In short, just a thorough server-side validation should be sufficient. – PajuranCodes Nov 03 '19 at 19:03
  • As for the sanitization, I don't see why a different approach would be necessary. Of course, this is my personal point of view. Other people have different opinions on the validation/sanitization subject. – PajuranCodes Nov 03 '19 at 20:46
  • Okay that makes sense, however if validation fails on the backend should I take the contents of the InvalidCredentials object and pass that back to the frontend as a response so a user can see what the problem is? – SneakyShrike Nov 05 '19 at 14:37
  • That's great I appreciate your help. – SneakyShrike Nov 06 '19 at 11:32
  • Regarding the question in your last comment: If the validation fails strictly because the user entered invalid data, then yes, throw an exception of type `InvalidData`. This means to create a class extending the abstract class `InvalidData` - like my `InvalidEmail` or `InvalidPassword`, for example - and to throw an instance of the new class. P.S: I created a [chat room](https://chat.stackoverflow.com/rooms/201914/domain-object-and-data-mapper-interaction-within-a-service-in-an-mvc-app). If you have further questions, I'll be glad to answer them in there, if I can. – PajuranCodes Nov 11 '19 at 22:07