SOLID

5 characters for better code

Jan-Oliver Pantel

@JanPantel

 

- CouchCommerce

- Hochschule Hannover

SOLID

  • First introduced in the early 2000's
  • By Robert 'Uncle Bob' Martin
  • Good foundation for application design
  • Collection of 5 software design principles
    • All are connected
    • Breaking one often means breaking multiple
  • Understanding SOLID results in higher understanding of the most design patterns

S

ingle Responsibility

class ProductController extends MvcController
{
    fn save()
    {
        if ( ! this.request.has(['price', 'name']))
        {
            return new BadRequestResponse('Missing input data!');
        }

        if (this.database.table('products').query().whereEquals('name', this.request.body.name).count() > 0)
        {
            return new BadRequestResponse('The name is already taken!');
        }

        if (this.request.body.price <= 0) 
        {
            return new BadRequestResponse('The price must be greater than 0!');
        }


        var product = new Product();
        product.price = this.request.body.price;
        product.name = this.request.body.name;

        try
        {
            product.save();
        }
        catch (DatabaseError e)
        {
            return new InternalServerErrorResponse('Error saving the product!');
        }

        return new Response(product);       
    }
}

Looks good?

Nope....

 

  • What are the responsibilities of this class?
    • Handle the "save product"-endpoint
  • What tasks do this class tackle in real?
    • Validation of the request
    • Validation against domain rules
    • Database interaction
    • HTTP logic

The Single Responsibility Principle

 

[...] the single responsibility principle states that every class should have responsibility over a single part of the functionality [...] and that responsibility should be entirely encapsulated by the class.

- wikipedia.com

Problems?

  • We always have to alter the ProductController if
    • the domain logic changes
    • we use another data store
  • But the only reason it should change if we use another protocol
    • Responsibility of the controller: Transport (in this case HTTP)

Solution

class ProductRepository
{
    fn construct(Database database);

    fn exists(name)
    {
        return this.database
            .table('products')
            .query()
            .whereEquals('name', name)
            .count() > 0;
    }

    fn create(data)
    {
        var product = new Product();
        product.price = data.price;
        product.name = data.name;

        try
        {
            product.save();
        }
        catch (DatabaseError e)
        {
            return null;
            //Or throw any domain exception
            //for a more explicit error reporting
        }

        return product;
    }    
}
class SaveProductRequestValidator
{
    fn construct(ProductRepository productRepo);

    fn validate(data)
    {
        var errors = [];

        if ( ! data.contains(['price', 'name']))
        {
            errors.add('Missing input data!'));
        }

        if (productRepo.exists(data.name))
        {
            errors.add('Name already taken!');
        }

        if (data.price <= 0) 
        {
            errors.add('The price must be greater than 0!');
        }
        

        return errors;
    }
}

The Controller

class ProductController extends MvcController
{
    fn construct(ProductRepository productRepo, SaveProductRequestValidator validator);

    fn save()
    {
        var validationResult = this.validator.validate(this.request.body);
        if ( ! validationResult.empty())
        {
            return new BadRequestResponse(validationResult);
        }

        product = this.productRepo.create(this.request.body);

        return product != null
            ? new Response(product)
            : new InternalServerErrorResponse('Error saving product!');       
    }
}

O

pen Closed Principle

class SaveProductRequestValidator
{
    fn construct(ProductRepository productRepo);

    fn validate(data)
    {
        var errors = [];

        if ( ! data.contains(['price', 'name']))
        {
            errors.add('Missing input data!'));
        }

        if (productRepo.exists(data.name))
        {
            errors.add('Name already taken!');
        }

        if (data.price <= 0) 
        {
            errors.add('The price must be greater than 0!');
        }
        

        return errors;
    }
}

Drawbacks

  • Class needs to be modified if
    • business rules change
    • more validations needed
  • Possibility of breaking the application
  • We need a VCS to rollback changes

The Open Closed Principles

[...]the open/closed principle states "software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification"; that is, such an entity can allow its behaviour to be extended without modifying its source code.

- wikipedia.com

Solution

interface SaveProductRequestValidatorInterface
{
    fn validate(data);
}
class SaveProductRequestValidator
    implements SaveProductRequestValidatorInterface
{
    fn construct(ProductRepository productRepo);

    fn validate(data)
    {
        // ...
    }
}
class ProductController extends MvcController
{
    fn construct(
        ProductRepository productRepo,
        SaveProductRequestValidatorInterface validator
    );

    fn save()
    {
        // ...    
    }
}

Solution

class ExtendedSaveProductRequestValidator
    extends SaveProductRequestValidator
{
    fn construct(ProductRepository productRepo);

    fn validate(data)
    {
        errors = parent.validate(data);

        if (data.price > 999)
        {
            errors.add('Price needs to be lower than 1000!');
        }
    }
}
  • Now we are able to inject the new validator to the controller
  • We didn't modified old code
    • We can easily rollback to the old logic
  • We didn't modified the controller
class PresenceSaveProductRequestValidator
    extends SaveProductRequestValidatorInterface
{
    fn construct(ProductRepository productRepo);

    fn validate(data)
    {
        if ( ! data.has(['name', 'price']))
        {
            return ['Missing input attributes!'];
        }
        return [];
    }
}

L

iskov Substitution Principle

interface ProductRepositoryInterface
{
    fn exists(name);
    fn create(data);
}
class XmlProductRepository implements ProductRepositoryInterface
{
    //let's assume the document is already parsed in this object
    fn construct(XmlDocument xml);

    fn exists(name)
    {
        return this.xml.xpath('//product[@name=\'' + name + '\']').count() > 0;
    }

    fn create(data)
    {
        // ...
    }
}

Switching implementations

class DatabaseProductRepository implements ProductRepositoryInterface
{
    //let's assume our db connection does not connect automatically
    fn construct(Database database);

    fn connect()
    {
        if ( ! this.database.isConnected())
        {
            this.database.connect();
        }
    }

    fn exists(name)
    {
        return this.database
            .table('products')
            .query()
            .whereEquals('name', name)
            .count() > 0;
    }

    fn save(data)
    {
        // ...
    }
}

The controller

The controller now has to be aware of this implementation detail

class ProductController extends MvcController
{
    fn save()
    {
        if (this.productRepo instanceof DatabaseProductRepository)
        {
            this.productRepo.connect();
        }

        product = this.productRepo.save(this.request.body);

        // ...
    }
}

Drawbacks

  • The controller has to be aware of implementation details
  • The controller is coupled to the database repository class
  • You can't drop in the database repository without altering the controller
    • By default there is no need for the controller to change

Liskov substitution principle

[...] states that [...] if S is a subtype of T, then objects of type T may be replaced with objects of type S [...] without altering any of the desirable properties of that program (correctness, task performed, etc.)

- wikipedia.com

Solution

class DatabaseProductRepository implements ProductRepositoryInterface
{
    //let's assume our db connection does not connect automatically
    fn construct(Database database);

    fn connect()
    {
        if ( ! this.database.isConnected())
        {
            this.database.connect();
        }
    }

    fn exists(name)
    {
        this.connect();

        return this.database
            .table('products')
            .query()
            .whereEquals('name', name)
            .count() > 0;
    }

    fn save(data)
    {
        this.connect();

        // ...
    }
}

I

nterface segragation principle

class Car
{
    fn drive(metersPerSecond, seconds = 1)
    {
        this.position += metersPerSecond * seconds;
    }

    fn activateCruiseControl()
    {
        this.cruiseControl.control(this);
    }
}

Anti-Pattern described with classes

Now let's implement a bycycle... hey cheap we can extend Car because we need the same implementation of drive()

class Bycycle extends Car
{
    fn activateCruiseControl()
    {
        throw new Error('bikes do not have any cruise control!');
    }
}

JACKPOT!

IT WORX WITH NEARLY NO EFFORT!!!11

Drawbacks :(

  • The class Bycicle now has the method activateCruiseControl() from the Car
  • The method does not make any sense at all
  • By default the method throws an exception

Interface segregation principle

The interface-segregation principle (ISP) states that no client should be forced to depend on methods it does not use. ISP splits interfaces which are very large into smaller and more specific ones so that clients will only have to know about the methods that are of interest to them.

- wikipedia.com

Solution

abstract class DriveableVehicle
{
    fn drive(metersPerSecond, seconds = 1)
    {
        this.position += metersPerSecond * seconds;
    }
}
class Car extends DriveableVehicle
{
    fn activateCruiseControl()
    {
        this.cruiseControl.control(this);
    }
}
class Bycycle extends DriveableVehicle
{

}

Side node:

You can also use traits in some languages

Back to interfaces

interface CollectionInterface
{
    fn add(entry);
    fn has(entry);
    fn get(entry);
}
class ReadonlyCollection implements CollectionInterface
{
    fn add(entry)
    {
        throw new Error('This collection is read-only!');
    }

    // ...
}

Solution

interface CollectionInterface
{
    fn has(entry);
    fn get(key);
}
interface MutableCollectionInterface extends CollectionInterface
{
    fn add(entry);
}
class ReadOnlyCollection implements CollectionInterface
{
    fn has(entry)
    {
        return this.items.has(entry);
    }

    fn get(key)
    {
        return this.items[key];
    }
}

Dependency Inversion Principle

High-Level-Code

interface ValidatorInterface
{
    fn validate(data);
}

Mid-Level-Code

interface SaveProductRequestValidatorInterface
    extends ValidatorInterface
{
    
}
class SaveProductRequestValidator
    implements SaveProductRequestValidatorInterface
{
    fn construct(Database database);

    fn validate(data)
    {
        // ...
    }
}

Low-Level-Code

Anti Pattern I

High-/Mid-Level code couples to Low-Level code

class Product extends OrmModel
{
    fn getName()
    {
        return this.attributes.name;
    }

    fn setName(name)
    {
        this.attributes.name = name;
    }

    fn category()
    {
        return this.hasOne('category');
    }

    fn getCategory()
    {
        return this.category().getRelatedModel();
    }
}
interface ProductRepositoryInterface
{
    fn getById(id) : Product;
}
var product = this.productRepo.getById(1337);

var category = product.category().getRelatedModel();

Problem of this code

  • The high-level-code (repository) depends on low level code
  • The Product class is tightly coupled to the ORM (so other code will become coupled)

The Dependency Inversion Principle

This principle states that high-level code should not depend on low-level code, and that abstractions should not depend upon details.

- Taylor Otwell - From Apprentice to Artisan

Solution

interface ProductInterface
{
    fn getName();
    fn setName();
    fn getCategory() : CategoryInterface;
}
interface ProductRepositoryInterface
{
    fn getById(id) : ProductInterface;
}
class Product extends OrmModel implements ProductInterface
{
    fn getName()
    {
        return this.attributes.name;
    }

    fn setName(name)
    {
        return this.attributes.name = name;
    }

    fn getCategory() : CategoryInterface
    {
        return this.hasOne('category').getRelatedModel();
    }
}

Anti-Pattern II

Implementation is coupled to other implementations

class DatabaseProductRepository implements ProductRepositoryInterface
{
    fn construct(MysqlDatabase database);

    fn getById(id)
    {
        return this.database.find(id);
    }

    // ...
}

Solution

class DatabaseProductRepository implements ProductRepositoryInterface
{
    fn construct(DatabaseInterface database);

    fn getById(id)
    {
        return this.database.find(id);
    }

    // ...
}

OK cool...

... but I always forget about these principles

More SOLID code for everyone

  • Always write unit tests (before writing implementations)
    • Often you will recognize violations of SOLID right before
  • Always take a minute after you've written a class to think about SOLID

Questions for good code

  • Does my class have more than one reason to change?
    • Calculations + model interaction = 2 reasons
  • Do I need to alter my code to embrace changes of the environment?
  • Do I have to alter dependent classes if I reimplement this?
  • Is the interface I've used very explicit for the technology I've used?
  • Do I really have an interface?

THANKS!

:)

Made with Slides.com