Alexander Steshenko | On Software Engineering

Registering a company

Preparations

We’re not going to have any User Interface at this stage of development. No views, no controllers: we’ll just create and test our model. This should help web developers who could not even imagine approaching an architecture like this. Here is the directories structure I suggest for the app

Automated unit tests will go to the “tests” directory. “Services” is for Service Layer, “Domain” is for Domain Layer and “Infrastructure” is the place where some application logic will go to keep our services clear (e.g. special class for sending emails). To find out more about layering in general and about this particular choice I recommend to read a book by Eric Evans called “Domain Driven Design”, the “Layered Architecture” part. The “configs” directory is to hold all the configuration files our application may need.

Things to get started

Dependencies

There are many ways to manage dependencies: global variables, registry, Inversion of Control, Service Locator pattern. A good container is always better in real life, however for simplicity sake I will use Service Locator pattern in this application, to avoid extra dependencies.

Config

At this moment we’re just going to have one configuration file: “configs/config.ini”. To access the file I’ll utilize Zend\Config. You’ll see how we do it in the ServiceLocator class.

APPLICATION\PATH constant  

APPLICATION\PATH is defined everywhere and refers to the “application” directory. It should be defined in any entry point our application has. For instance when we add MVC layer and use index.php file in Document Root on our web server - that would be the place where the constant is defined. Another example is bootstrap.php which I’m going to use as entry point for our Test Suite.

ORM

I’m going to use Doctrine’s EntityManager without any additional abstractions added. EntityManager will be used from services only for persisting entities and for transactions. Repositories, however, will be only retrieved from ServiceLocator.

It is not generally a good practice to depend on any ORM in your services. While it’s not a matter of this series of articles you may want to add an additional abstraction layer in your projects.

We also need a directory to store proxies for domain entities that Doctrine ORM generates. Those are going to “Infrastructure/Proxies”.

Adjustable Doctrine parameters will go to the config.ini file.

The First Task

As starting point I chose the process of registering a company in the system. This seems to be logical as it’s actually the starting point for the end-users. First, let’s describe the process once again:

In order to register a company, customers enter their company name, identifier for the subscription plan’s they choose and data for their first admin user account: email, name and password repeated twice to avoid mistakes. As the result: a message with email confirmation link is sent, a new not activated company is created and new user admin account is created.

Now, let’s stick to terms of object oriented programming and define objects this process includes and known domain logic details:

To hold the process itself we’re going to use a Service Layer method, so let it be CompanyService and registerCompany method which will do the following:

  1. finds the subscription plan by its identifier in the data storage
  2. error if the plan is for some reason not found
  3. error if two passwords provided are not equal
  4. creates new user admin account based on the email, name and password provided
  5. creates company based on company name provided, new admin user and the subscription plan found
  6. saves the new company in the data storage

Note how the requirements translate into code:

namespace Domain;

use Doctrine\Common\Collections\ArrayCollection;

class Company
{
    protected $id;

    protected $name;

    protected $subscription;

    protected $isActivated;

    protected $users;

    public function __construct($name, Subscription $subscription, User $admin)
    {
        $this->users = new ArrayCollection();
        if (!$name) {
            throw new \DomainException('Company name cannot be empty');
        }
        $this->name = $name;
        $this->subscription = $subscription;
        $this->isActivated = false;
        if (!$admin->isAdmin()) {
            throw new \DomainException('User must be a new admin in order to create a company');
        }
        $this->addUser($admin);
    }

    protected function addUser(User $newUser)
    {
        foreach ($this->users as $existingUser) {
            if ($existingUser == $newUser) {
                throw new \DomainException('User is in the company already');
            }
        }
        $newUser->setCompany($this);
        $this->users[] = $newUser;
    }
}
namespace Domain;

class User
{
    protected $id;

    protected $name;

    protected $email;

    protected $passwordHash;

    protected $isAdmin;

    protected $company;

    public function __construct($email, $name, $password, $isAdmin = false)
    {
        if (!\Zend_Validate::is($email, 'EmailAddress')) {
            throw new \DomainException('Email is not valid');
        }
        if (6 > strlen($password)) {
            throw new \DomainException('Wrong password length');
        }
        $this->email = $email;
        $this->name = $name;
        $this->passwordHash = sha1($password);
        $this->isAdmin = $isAdmin;
    }

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

    public function setCompany(Company $company)
    {
        if (!$this->company) {
            $this->company = $company;
        } else {
            throw new \DomainException('The user already belongs to a company');
        }
    }
}
namespace Domain;

class Subscription
{
    protected $id;

    protected $name;
}
namespace Services;

use Domain\Company,
    Domain\User;

class CompanyService
{
    public function registerCompany($subscriptionId, $companyName, $adminName, $adminEmail,
        $adminPassword, $adminPasswordRepeated)
    {
        // Getting objects we're going to need in this service, using our ServiceLocator
        $subscriptionRepository = \ServiceLocator::getSubscriptionsRepository();
        $entityManager = \ServiceLocator::getEm();

        // 1. finds the subscription plan by its identifier in the data storage
        $subscription = $subscriptionRepository->find($subscriptionId);
        // 2. error if the plan is for some reason not found
        if (!$subscription) {
            throw new \DomainException('Subscription is not found');
        }
        // 3. error if two passwords provided are not equal
        if ($adminPassword != $adminPasswordRepeated) {
            throw new \DomainException('Passwords are not equal');
        }
        // 4. creates new user admin account based on the email, name and password provided
        $adminUser = new User($adminEmail, $adminName, $adminPassword, true);
        // 5. creates company based on company name provided, new admin user and the subscription plan found
        $company = new Company($companyName, $subscription, $adminUser);
        // 6. saves the new company in the data storage
        $entityManager->persist($company);
        $entityManager->flush();
    }
}

Adding missing parts to the ServiceLocator class:

class ServiceLocator 
{

    protected static $em;
    protected static $db;
    protected static $cache;
    protected static $config;

    public static function getDb()
    {
        if (self::$db === null) {
            $dbConfig = self::getConfig()->get('doctrine')->get('db');
            self::$db = Doctrine\DBAL\DriverManager::getConnection($dbConfig->toArray());
        }

        return self::$db;
    }

    public static function getCache()
    {
        if (self::$cache === null) {
            $doctrineConfig = self::getConfig()->get('doctrine');
            $cacheClass = $doctrineConfig->get('cacheClass');
            self::$cache = new $cacheClass;
        }

        return self::$cache;
    }

    /**
     * @return Zend_Config
     */
    public static function getConfig()
    {
        if (self::$config === null) {
            self::$config = new Zend_Config_Ini(APPLICATION_PATH . '/configs/config.ini');
        }

        return self::$config;
    }

    /**
     * @return Doctrine\ORM\EntityManager
     */
    public static function getEm()
    {
        if (self::$em === null) {
            $cache = self::getCache();
            $db = self::getDb();
            $config = new \Doctrine\ORM\Configuration();
            $config->setMetadataCacheImpl($cache);
            $config->setQueryCacheImpl($cache);
            $config->setMetadataDriverImpl(
                $config->newDefaultAnnotationDriver(APPLICATION_PATH . '/models'));
            $config->setProxyDir(APPLICATION_PATH . '/models/Infrastructure/Proxies');
            $config->setProxyNamespace('Infrastructure\Proxies');
            $config->setAutoGenerateProxyClasses(false);
            self::$em = \Doctrine\ORM\EntityManager::create($db, $config);
        }

        return self::$em;
    }

    public static function getSubscriptionsRepository()
    {
        return self::getEm()->getRepository('\Domain\Subscription');
    }
}

and add parameters doctrine needs to config.ini

;  Doctrine 2 configuration 
doctrine.cacheClass = "\Doctrine\Common\Cache\ArrayCache"
doctrine.db.dbname = "basiccrm"
doctrine.db.user = "basiccrm"
doctrine.db.password = "123456"
doctrine.db.host = "127.0.0.1"
doctrine.db.driver = "pdo_mysql"

To conclude, here is the result structure of the project’s directory:


Comments and feedback are much appreciated. See contact details here