DEV Community

loading...
Cover image for  Basics of Object Design - Part One

Basics of Object Design - Part One

Oliver Mensah
Software Engineer | Developer Programs Enthusiast
Updated on ・10 min read

In my previous post, Basics of Object Design Series, I shared about the book I have been reading on object design, Style Guide for Object Design by Matthias Noback and also sharing my learnings from it through series of blog posts. So far, I have read the first chapter, creating service objects. In this article, we will discuss certain principles that can guide software professionals while creating service objects.

As software professionals, our main goal is to solve problems. Modeling has been one of the approaches to problem-solving. Through object-oriented modeling which involves the practice of representing problem domains as objects in your software. Thinking in terms of objects and representing them in code keeps everything organized, flexible, and reusable because objects are all around us. In an application, there will be two types of objects that are likely to be designed and created. And they are;

i) objects for performing a task or giving out information
ii) objects as data storage and their manipulation

Objects that perform a task are referred to as services. They are publicly known to be doing one thing. For instance, if we take an object like FileLogger - logging files is the only functionality that will come to mind. This makes intuitive sense right. And usually, we can get what a service does from even its name.

Common things we should know about services.

They are created immutably: *Thus they are created once and can be used multiple times without causing changes to their functionality and properties. That means when service objects are created, they should not behave unpredictably *

They are doers: Any object that is using service object can tell what that service does specifically. For instance, having a service with a name Router tells that this service is meant for routing functionality in an application

Key Principles of Creating Service Objects.

1) Everything needed to create the object must be injected as a constructor argument.

Imagine creating a Router object. We need to provide what will be needed by a Router class to create an object. These dependencies should be injected through the constructor. This makes it well-prepared for use and there is no reconfiguration after its creation. Let's try this in code.

<?php 

class Router
{
    public function __construct(array $routes)
    {
        $this->routes = $routes;
    }

    public function route($route): string
    {
        if (array_key_exists($route, $this->routes))
        {
            return $this->routes[$route];
        }

    }
}

$routes = array(
    "/" => "https://omensah.github.io",
    "/resume" => "https://omensah.github.io/resume", 
    "/contact" => "https://omensah.github.io/contact"
);
$routers = new Router($routes);
echo $routers->route("/");
Enter fullscreen mode Exit fullscreen mode

From the code, we can tell that the constructor needs only the routes dependency to prepare it for use.

2) Whenever an object needs configuration settings, they must be injected as constructor arguments.

Sometimes creating an object requires part of the object to be configured in order for its client to know about certain decisions. For instance, when you take a service object like a FileLogger, its main purpose is to log message(s) to a file. Well, we can log the message(s) to a file at any directory without the client of the object knowing about that specific location. To prevent that, all we need to do is to pass all the configurations as constructor arguments. Hence the client has explicit knowledge about the log files location. Let's try an example.


<?php 
final class FileLogger {

    public function __construct(string $logFilePath){

        $this->logFilePath = $logFilePath;
    }

    public function log(string $message): void {
        file_put_contents(
            $this->logFilePath,
            $message,
            FILE_APPEND
        );
    }

}

$logger = new FileLogger("/home/olivermensah/Desktop/practices/OD/logs.txt");
$logger->log('A message');

Enter fullscreen mode Exit fullscreen mode

3) Data Relevant for a task must be passed as method arguments, not a constructor argument.

You might have noticed in the first two examples, we have constructor and method arguments. Basically, constructor arguments are used to set what the entire object needs while method arguments are injected in a specific method to accomplish the task at hand. For example, in the FileLogger class, it really makes sense to pass the message parameter to the log method. Thus, it is the log method only that will need such a dependency to accomplish its task.

NB: If there are other operations that must work on the message parameter before using it in the logged method then the method argument won't be necessary.

4) All constructor arguments should be required

When an object can work without setting up dependencies through a constructor, we should not pass such dependencies at all. There is no such thing as optional dependency where you pass dependency value as null.
This introduces unnecessary code complications by always checking for nullity of that dependency before its usage. Let's try this in code.


<?php

// null object

class Car {
    private $model = '';
    public function __construct($model = null){
        if($model){
            $this -> model = $model;
        }
    }
    public function getCarModel(){
        return ' The car model is: ' . $this -> model;
    }
}


$car = new Car();
echo $car->getCarModel();
$car1 = new Car("BMW");
echo $car->getCarModel();

Enter fullscreen mode Exit fullscreen mode

These code complications or workaround can be solved through either providing default value, making all dependencies required or a null object.


<?php

// default value solution

class Car {
    private $model = '';
    public function __construct($model = "Mercedes"){
        $this -> model = $model;
    }
    public function getCarModel(){
        return ' The car model is: ' . $this -> model;
    }
}

$car = new Car();
echo $car->getCarModel();

$car1 = new Car("BMW");
echo $car->getCarModel();

//required dependency solution 

class Car {
    private $model = '';
    public function __construct(string $model){
        $this -> model = $model;
    }
    public function getCarModel(){
        return ' The car model is: ' . $this -> model;
    }
}


$car = new Car();
echo $car->getCarModel();
$car1 = new Car("BMW");
echo $car->getCarModel();


// null object

interface CarModel{
    public function getCarModel(): string;
}

class NullCarModel implements CarModel{
    public function getCarModel(): string{
        return ' The car model is: ';
    }
}

class MercedesCarModel implements CarModel{
    public function getCarModel(): string{
        return ' The car model is: is Mercedes';
    }
}

class Car {
    private $model = '';
    public function __construct(CarModel $carModel){
        $this->carModel = $carModel;
    }
    public function getCarModel(){
        return  $this->carModel->getCarModel();
    }
}


$car = new Car(new NullCarModel);
echo $car->getCarModel();

$car1 = new Car(new MercedesCarModel);
echo $car1->getCarModel();

Enter fullscreen mode Exit fullscreen mode

The above code introduces the default value, required dependency and null object to workaround optional dependency. This is because we don't want to optionally pass dependency, we want to pass dependency when needed for use. I think the null object provides a nice workaround and makes dependency more explicit to be injected. With the null object, what you have to do is to create a type for all the possible dependency using interface. These dependencies will be created using this type and injected later for use.
For instance, the car required a model as its dependency, I created a type called CarModel and all car models are created with this type and later injected via the constructor. So we passed NullCarModel when don't have any CarModel and MercedesCarModel for Mercedes car models. You can create as many models you want.

5). Use on constructor injection, not setter injection.

In creating a service object, it must be defined immutably. That's means methods that mutate the properties of a service object must not be allowed. Let's try in code;

final class Printer {

    public function ignoreErrors(bool $ignoreErrors): void{
        $this->ignoreErrors = $ignoreErrors;
    }
    public function print(){
        if($this->ignoreErrors) {
            echo "Error ignored";
        }
        else{
            echo "Errors not ignored";
        }
    }
// ...
}
$printer = new Printer();
$printer->ignoreErrors(false);
$printer->print();

Enter fullscreen mode Exit fullscreen mode

Looking at the code above, we have a method that can set the property of service object to a boolean state. This means the object is not going to be predictable.

Therefore, it is advisable to only use a constructor to set the values needed once and those values should not be changed as refactored below;


final class Printer {

    public function __construct(bool $ignoreErrors){
        $this->ignoreErrors = $ignoreErrors;
    }
    public function print(){
        if($this->ignoreErrors) {
            echo "Error ignored";
        }
        else{
            echo "Errors not ignored";
        }
    }
// ...
}
$ignoreErrors = true;
$printer = new Printer($ignoreErrors);
$printer->print();

Enter fullscreen mode Exit fullscreen mode

6) Inject what you an object needs, not where it can get it from.

When working on a large application with a large codebase, there is always a special service that holds other services for their easier usage. This special service might be called service locator or service registry or service manager.

The main goal of a service locator is to make other services available to be use easily by taking care of their instantiations. Let's imagine having Router and FileLogger service. They will be loaded into service locator. And any part of the application that needs any of the registered services, they can reach the service locator using a specific identifier and if the identifier is not found, an exception is thrown. Let's try this in code.


<?php 
final class FileLogger {

    public function __construct(string $logFilePath){

        $this->logFilePath = $logFilePath;
    }

    public function log(string $message): void {
        file_put_contents(
            $this->logFilePath,
            $message,
            FILE_APPEND
        );
    }

}

class Router
{
    public function __construct(array $routes)
    {
        $this->routes = $routes;
    }

    public function route($route): string
    {
        if (array_key_exists($route, $this->routes))
        {
            return $this->routes[$route];
        }

    }
}


final class ServiceLocator {

    public function __construct(){
        $this->services = [
            'FileLogger' => new  FileLogger("/home/olivermensah/Desktop/daily-practices/OD/log.txt"),
            "Router" => new Router(
                array(
                    "/" => "https://omensah.github.io",
                    "/resume" => "https://omensah.github.io/resume", 
                    "/contact" => "https://omensah.github.io/contact"
                )
            )
            // We can have any mumber of services here...
        ];
    }
    public function get(string $identifier): object{
        if (!isset($this->services[$identifier])) {
            throw new LogicException( 'Unknown service: ' . $identifier);
        }
        return $this->services[$identifier];
    }
}

//Injecting what you need to get them from service. 
final class FileLoggerController {

    public function __construct(FileLogger $fileLogger){
        $this->fileLogger = $fileLogger;
    }

    public function saveLog(){
        return $this->fileLogger->log("Oliver");
    }
}

$locator = new ServiceLocator();
$logger = new FileLoggerController($locator->get(FileLogger::class));
echo $logger->saveLog();

Enter fullscreen mode Exit fullscreen mode

In the above code, we only passed the FileLogger which is needed by the FileLoggerController to work. We could have passed the service and then later retrieve the necessary service. That goes against the style guide. Only pass what is needed not where you can get it from.

7) Make sure all dependencies are explicit.

Sometimes, we embed codes deeply in a class without letting the client of the object know whether that object has some dependencies. Let's look at this in code;


<?php

class Cache{
    private static $data = array(
        'post' => "Data is the new Gold", 
        'recent_post' => "Together we are building the tech scene in Ghana."
    );


    public static function has($key){
        return isset(self::$data[$key]);
    }

    public static function get($key){
        return self::$data[$key];
    }
}

final class DashboardController {
    public function getPost(){

        if (Cache::has('recent_post')) {
            echo Cache::get('recent_post');
        }else{
            echo "No Recent posts";
        }
    }
}


$ctrl = new DashboardController();
$ctrl->getPost();

Enter fullscreen mode Exit fullscreen mode

We can see that the DashboardController has Cache as a dependency but we did not explicitly let the client of this object know that it has a dependency. This must be avoided. Make sure that all dependencies are made explicitly known by clients. Let's refactor the previous code.

class Cache{
    private static $data = array(
        'post' => "Data is the new Gold", 
        'recent_post' => "Together we are building the tech scene in Ghana, one 
                        meetup at a time"
    );


    public static function has($key){
        return isset(self::$data[$key]);
    }

    public static function get($key){
        return self::$data[$key];
    }
}

final class DashboardController{

    public function __construct(Cache $cache){
        $this->cache = $cache;
    }
    public function getPost(){
        // $recentPosts = [];
        if ($this->cache->has('recent_post')) {
            // $recentPosts = Cache::get('recent_posts');
            echo $this->cache->get('recent_post');
        }else{
            echo "No Recent posts";
        }
    }

}

$ctrl = new DashboardController(new Cache());
$ctrl->getPost();

Enter fullscreen mode Exit fullscreen mode

Now any client using this DashboardController will know that it uses Cache as a dependency. This means never hide your dependencies, make them know through constructor or method injection.

8). Do nothing in the constructor function, Only assign properties.

Anytime that you are creating a service object, it means you are injecting constructor argument(s) to prepare it for use. Anything outside assigning the argument(s) to object property must be done in another method of the object, not in the constructor method. Let's review this code.

final class FileLogger{
    private  $logFilePath;

    public function __construct(string $logFilePath){
        $logFileDirectory = dirname($logFilePath);
        if (!is_dir($logFileDirectory)) {
            // create the directory if it doesn't exist yet
            mkdir($logFileDirectory, 0777, true);
        }
        touch($logFilePath);
        $this->logFilePath = $logFilePath;
    }
}

$logger = new FileLogger("/home/olivermensah/Desktop/oliver/d.txt");

Enter fullscreen mode Exit fullscreen mode

In the constructor, we are retrieving the directory of the giving path, checking to see if the directory does not exist, then creating that directory if possible and finally adding the file in that directory. That's a lot of work to be done by the constructor. We can do better by extracting those operations from the constructor into a separate method.


final class FileLogger{
    private $logFilePath;
    public function __construct(string $logFilePath){
        $this->logFilePath = $logFilePath;
    }
    public function log(): void{
        $this->ensureLogFileExists();
    }
    private function ensureLogFileExists(): void{
        if (is_file($this->logFilePath)) {
            return;
        }
        $logFileDirectory = dirname($this->logFilePath);
        if (!is_dir($logFileDirectory)) {
            // create the directory if it doesn't exist yet
            mkdir($logFileDirectory, 0777, true);
        }
        touch($this->logFilePath);
    }
}

$logger = new FileLogger("~/Desktop/oliver/a.txt");
$logger->log();
Enter fullscreen mode Exit fullscreen mode

Now, we have been able to extract the work done by the constructor into a different method.

Another means to avoid the constructor method to do a lot of work, is by setting up the necessary configurations before instantiating the class and the result is passed as a constructor argument. This is usually called bootstrapping.


final class FileLogger{
    public function __construct(string $logFilePath){
        /*
        * We expect that the log file path has already been
        * properly set up for us, so all we do here is a "sanity
        * check":
        */
        if (!is_writable($logFilePath)) {
            throw new InvalidArgumentException(sprintf(
                'Log file path "%s" should be writable',
                $logFilePath
            ));
        }
        $this->logFilePath = $logFilePath;
    }   
    public function log(string $message): void{
        // No need for a call to `ensureLogFileExists()` or anything
        // ...
    }
}
/*
* The task of creating the log directory and file should be moved to
* the bootstrap phase of the application itself:
*/
$logFilePath = "/home/olivermensah/Desktop/oliver/ca.txt";

if(!is_file($logFilePath)) {
    $logFileDirectory = dirname($logFilePath);
    if (!is_dir($logFileDirectory)) {
        // create the directory if it doesn't exist yet
        mkdir($logFileDirectory, 0777, true);
    }
    touch($logFilePath);
}
$logger = new FileLogger($logFilePath);


Enter fullscreen mode Exit fullscreen mode

9). Throw an exception when an argument is not valid

When the client of the class provides an argument that is not valid, the type checker will show warning(s). However, not in all cases that the client will pass the wrong type. Passing the right type but a value that does not make sense for the operation of the object methods. In that case, you will need to throw an exception to ensure sanity checks.


<?php 

class Score  {
    private $minimumLevel;
    public function __construct(int $minimumLevel) {
        if ($minimumLevel <= 0) {
            throw new InvalidArgumentException('Minimum alerting level should be greater than 0');
        }
        $this->minimumLevel = $minimumLevel;
    }
}

$score = new Score(-100);

Enter fullscreen mode Exit fullscreen mode

Conclusion

Having gone through the various style guides on how we can create good service objects within a software application, I think the following takeaways will solidify our fundamentals on how we can design service objects;

1) Everything needed to create the object must be injected as a constructor argument.

2) Whenever an object needs configuration settings, they must be injected as constructor arguments.

3) Data Relevant for a task must be passed as method arguments, not a constructor argument.

4) All constructor arguments should be required

5) Use on constructor injection, not setter injection.

6) Inject what you an object needs, not where it can get it from.

7) Make sure all dependencies are explicit.

8) Do nothing in the constructor function, Only assign properties.

9) Throw an exception when an argument is not valid

I hope you find them very useful.
NB: Let's discuss in the comment section your thoughts about these guides.

Further Reading

Matthias Noback's description is one of the best in-depth discussion of Style Guide for Object Design from a practical design principle perspective.

Keep In Touch

Let's keep in touch as we continue to learn and explore more about software engineering. Don't forget to connect with me on Twitter or LinkedIn

Discussion (12)

Collapse
bajzathd profile image
Bajzáth Dávid

Really nice write up, thank you! 👍
I have only one complaint, with the "Do nothing in the constructor function, Only assign properties" part.
I think in the first solution (extracting to a method) it could be called from the constructor, this is what I usually do if it is not a complicated task. This way it only runs once, and the constructor is the place where you should prepare all of your dependencies, like you said above.
The second method (expecting bootstrapping) adds a hidden dependency, as the logging will fail if the file is not present. Moving it out of the class can be a good idea (especially if it is complex), but I would move it to a Factory class, and make the FileLogger constructor only visible to that class.
I would love to hear your opinion on this.

Collapse
olivermensahdev profile image
Oliver Mensah Author

Yeah. You can absolutely call that in the constructor as well to make it run once on the entire object. That would be a good approach to do a sanity check before logging. Thanks for the feedback.

For the second approach with bootstrapping, the dependency is still not hidden. Just that we pass it from the bootstrap face. There are some exceptions that might happen with this, so you will need to throw some exception depending on the issue that might occur. The article just focussed on only one exception type but there are others that we can also look at.

Great feedback.

Collapse
bajzathd profile image
Bajzáth Dávid

Thank you for your answer!

I meant hidden as you can create a FileLogger instance without error even if you do not create the file for it, and it will only fail when the first logging takes place, not at creation time.

Thread Thread
olivermensahdev profile image
Oliver Mensah Author

Thank you for the explanation. Very much appreciated

Collapse
olivermensahdev profile image
Oliver Mensah Author

Can you show your proposed solution in code? I mean that of the second method, bootstrapping.

Collapse
bajzathd profile image
Bajzáth Dávid • Edited

I wrote PHP code years ago, but I forgot it does not have nested classes like Java, that is what I was referencing with "make the FileLogger constructor only visible to that class", but you can do a similar thing if you make the constructor protected and move the Factory class to the same package.

final class FileLogger{
    private $logFilePath;
    protected function __construct(string $logFilePath){
        $this->logFilePath = $logFilePath;
    }
    public function log(): void{
        $this->ensureLogFileExists();
    }

}

final class FileLoggerFactory {

    public function create(string $logFilePath): FileLogger {
        ensureLogFileExists($logFilePath);
        return new FileLogger($logFilePath);
    }

    private function ensureLogFileExists(string $logFilePath): void{
        if (is_file($logFilePath)) {
            return;
        }
        $logFileDirectory = dirname($logFilePath);
        if (!is_dir($logFileDirectory)) {
            // create the directory if it doesn't exist yet
            mkdir($logFileDirectory, 0777, true);
        }
        touch($logFilePath);
    }
}

Also, I am new here and I have no idea how to have syntax highlight :(

Thread Thread
olivermensahdev profile image
Oliver Mensah Author • Edited

I have seen the approach as well. Thanks. So allowing the Factory object to new up the object.

For the code highlighting, you can use
3start-backticts
langauge(php, js, py, etc)

3end-backticks

Collapse
aleksikauppila profile image
Aleksi Kauppila

Very nice post! 👍 👍 these are very important practices to keep the program understandable and consistent. I also think that emphasizing reusability is not that important compared to introducing MEANING into the application.

I wrote about setters a while ago and it touches this same subject: dev.to/aleksikauppila/discarding-s...

Collapse
olivermensahdev profile image
Oliver Mensah Author

I will have a look at your work as well. Thanks for the feedback.
I think both would be well achieved having the fundamentals right. Also, think introducing the meaning into the application is really important.

Collapse
aleksikauppila profile image
Aleksi Kauppila

Yes, both reusability and meaning are achieved with the examples in this article 👍

Collapse
alexjon55818140 profile image
Alex Jones

A good article, although this is not really my specialization, but it became a source Top Online Casinos in Australia site reworking, under the principles you described, I will not say that it was easy, but we managed it.