Search code examples
phpmodel-view-controllerdatamapperslim-3domain-object

How should the domain object and data mapper interact within a service class for an auth system using MVC


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.


Solution

    • 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: