Package Design Principles in Practice

Minsk, Belarus

Maks Rafalko

@maks_rafalko

Cohesion

Coupling

Cohesion - what classes belong to a package

Coupling - relations between packages

  • Release/Reuse Equivalency Principle
  • Common Closure Principle
  • Common Reuse Principle
  • Acyclic Dependencies Principle
  • Stable Dependencies Principle
  • Stable Abstractions Principles

Package Cohesion

Release/Reuse Equivalency Principle

You can only reuse the amount of code that you can actually release

Release/Reuse Equivalency Principle

  • be able to manage future releases
  • tests & CI
  • documentation
  • SemVer and BC breaks
  • README / CONTRIBUTING / CHANGELOG / LICENSE

Release/Reuse Equivalency Principle

Release/Reuse Equivalency Principle

“Before software can be reusable it first has to be usable.” – Ralph Johnson

Common Reuse Principle

Classes that are used together are packaged together

Infection

PHP Mutation Testing Framework

Common Reuse Principle

Common Reuse Principle

Common Reuse Principle

{
    "require": {
        "php": "^7.3",
        "ext-dom": "*",
        "ext-json": "*",
        "ext-libxml": "*",
        "symfony/yaml": "^3.4.29 || ^4.0 || ^5.0",
        "symfony/console": "^3.4.29 || ^4.0 || ^5.0",
        "symfony/filesystem": "^3.4.29 || ^4.0 || ^5.0",
        "symfony/finder": "^3.4.29 || ^4.0 || ^5.0",
        "symfony/process": "^3.4.29 || ^4.0 || ^5.0",
        "composer/xdebug-handler": "^1.3.3",
        "justinrainbow/json-schema": "^5.2",
        "nikic/php-parser": "^4.2.2",
        "sanmai/pipeline": "^3.1",
        "sebastian/diff": "^3.0.2 || ^4.0",
        "seld/jsonlint": "^1.7"
    }
}

infection/core

Common Reuse Principle

Common Reuse Principle

{
    "require": {
        "php": "^7.3",
        "ext-dom": "*",
        "ext-json": "*",
        "ext-libxml": "*",
-       "symfony/yaml": "^3.4.29 || ^4.0 || ^5.0",
        "symfony/console": "^3.4.29 || ^4.0 || ^5.0",
        "symfony/filesystem": "^3.4.29 || ^4.0 || ^5.0",
        "symfony/finder": "^3.4.29 || ^4.0 || ^5.0",
        "symfony/process": "^3.4.29 || ^4.0 || ^5.0",
        "composer/xdebug-handler": "^1.3.3",
        "justinrainbow/json-schema": "^5.2",
        "nikic/php-parser": "^4.2.2",
        "sanmai/pipeline": "^3.1",
        "sebastian/diff": "^3.0.2 || ^4.0",
        "seld/jsonlint": "^1.7"
    }
}

infection/core

Common Reuse Principle

Violation inside Infection

  • Mutation Score badge requires ext-curl to be installed
  • Not everyone uses Mutation Badge, thus ext-curl is not in composer.json
  • As a result: late runtime errors instead of errors on `composer require ...` level

Problem

Solution

# composer.json
{
    "name": "infection/mutation-badge",
    "require": {
        "ext-curl": "*"
    }
}

Common Closure Principle

Common Closure Principle

# composer.json
{
    "name": "infection/codeception-adapter",
    "conflict": {
        "codeception/codeception": "<3.1.1"
    }
}

Common Reuse Principle

Instead of real dependencies, only suggestions:

# composer.json
{
    "suggest": {
        "graylog2/gelf-php": "Allow sending to a GrayLog2 server",
        "sentry/sentry": "Allow sending to a Sentry server",
        "doctrine/couchdb": "Allow sending to a CouchDB server",
        "ruflin/elastica": "Allow sending to an Elastic Search server",
        "php-amqplib/php-amqplib": "Allow sending to an AMQP server",
        "ext-amqp": "Allow sending to an AMQP server (1.0+ required)",
        "ext-mongo": "Allow sending to a MongoDB server",
        "mongodb/mongodb": "Allow sending to a MongoDB",
        "aws/aws-sdk-php": "Allow sending to AWS services like DynamoDB",
        "rollbar/rollbar": "Allow sending to Rollbar",
        "php-console/php-console": "Allow sending to Google Chrome"
    }
}

monolog/monolog

Common Reuse Principle

  • code in the package about the same thing
  • no optional dependencies
  • no dead/unused code
  • less chance of conflicted dependencies

Conclusion

Common Closure Principle

The classes in a package should be closed together against the same kinds of changes.

Common Closure Principle

Common Closure Principle

Common Closure Principle

GET    /api/products?featureId=234

[
    {"id":  1, "name":  "Product 1"},
    {"id":  2, "name":  "Product 2"},
    {"id":  3, "name":  "Product 3"},
    {"id":  4, "name":  "Product 4"}
]

// 4 elements (all included)

Before (2.4.3)

After (2.4.7)

[]

// 0 elements (all excluded)
SELECT .. FROM .. WHERE p.feature_id=0
SELECT .. FROM ..

GET    /api/products?featureId=string

Common Closure Principle

Also, Common Reuse Principle is violated

Common Closure Principle

  • classes with different reasons to changes must be placed in different packages
  • helps to prevent releasing packages with irrelevant changes for users

Conclusion

Packages Coupling

Acyclic Dependencies Principle

The dependency structure between packages must be a directed acyclic graph.

 

Acyclic Dependencies Principle

No cycles

With cycle

Acyclic Dependencies Principle

Acyclic Dependencies Principle

public function runTests(TestFrameworkAdapterInterface $adapter): void
{ 
    // ...
    
    if ($adapter->testsPass()) {
    	// ...
    }
}
class PhpUnitAdapter implements TestFrameworkAdapterInterface {}

class PhpSpecAdapter implements TestFrameworkAdapterInterface {}

class CodeceptionAdapter implements TestFrameworkAdapterInterface {}

Acyclic Dependencies Principle

Acyclic Dependencies Principle

Case with release a new major 2.0.0 version

  • TestFrameworkAdapterInterface has a BC break
  • infection/core 2.0.0 needs to be released
  • it depends on infection/phpunit-adapter which is not yet released
  • infection/phpunit-adapter 2.0.0 needs to be released
  • it depends on infection/core which is not yet released
interface TestFrameworkAdapterInterface
{
    public function testsPass(): bool;

+   public function getVersion(): string;
}

Acyclic Dependencies Principle

# project's composer.json
{
    "require-dev": {
        "infection/core": "^1.0",
        "infection/phpunit-adapter": "^1.0"
    }
}
# project's composer.json
{
    "require-dev": {
        "infection/core": "^1.0"
    }
}

Stable Dependencies Principle

  • The dependencies between packages in a design should be in the direction of the stability of the packages.
  • A package should only depend upon packages that are more stable than it is.

 

Stable Dependencies Principle

Stable, Irresponsible

Unstable

Responsible

Stable Dependencies Principle

I=\cfrac{C_{out}}{C_{in} + C_{out}}

Instability Value

  • I - instability value
  • Cout - the number of classes outside the package that any class inside the package depends upon
  • Cin - the number of classes outside the package that depends on a class inside the package.

Stable Dependencies Principle

I=\cfrac{C_{out}}{C_{in} + C_{out}}

Instability Value

I=\cfrac{0}{3 + 0}=0

Stable Dependencies Principle

I=\cfrac{C_{out}}{C_{in} + C_{out}}

Instability Value

I=\cfrac{1}{3 + 1}=0.25

Stable Dependencies Principle

I=\cfrac{C_{out}}{C_{in} + C_{out}}

Instability Value

I=\cfrac{3}{3 + 3}=0.5

Stable Dependencies Principle

I=\cfrac{C_{out}}{C_{in} + C_{out}}

Instability Value

I=\cfrac{5}{3 + 5}=0.625

Stable Dependencies Principle

# phpspec adapter's composer.json
{
    "require": {
        "infection/core": "^0.13 || ^0.14 ... || 1.0.0",
        "symfony/yaml": "^3.4.29 || ^4.0 || ^5.0",
    }
}

Stable Dependencies Principle

I=\cfrac{C_{out}}{C_{in} + C_{out}}

Instability Value

Stable Dependencies Principle

# phpspec adapter's composer.json
{
    "require": {
-       "infection/core": "^0.13 || ^0.14 ... || 1.0.0",
+       "infection/abstract-testframework-adapter": "^1.0.0"
    }
}

I=0

Stable Dependencies Principle

I=\cfrac{C_{out}}{C_{in} + C_{out}}

Instability Value

Stable Abstractions Principle

  • Packages should depend in the direction of abstractness.
  • Packages that are maximally stable should be maximally abstract.

 

Stable Abstractions Principle

A=\cfrac{C_{abstract}}{C_{concrete} + C_{abstract}}

Abstractness Value

  • Cabstract - number of abstract classes and interfaces
  • Cconcrete = number of concrete classes

Stable Abstractions Principle

A=\cfrac{C_{abstract}}{C_{concrete} + C_{abstract}}

Abstractness Value

Cohesion - what classes belong to a package

Coupling - relations between packages

  • Release/Reuse Equivalency Principle
  • Common Closure Principle
  • Common Reuse Principle
  • Acyclic Dependencies Principle
  • Stable Dependencies Principle
  • Stable Abstractions Principles

Package Autodiscovery

Before:

After:

# project's composer.json
{
    "require-dev": {
        "infection/core": "^1.0",
        "infection/codeception-adapter": "^1.0"
    }
}
# project's composer.json
{
    "require-dev": {
        "infection/core": "^1.0"
    }
}

Package Autodiscovery

  • Packages autoinstaller
  • Packages autodiscovery
  • Zero configuration

Package Autodiscovery

Package Autodiscovery

{
    "name": "infection/codeception-adapter",
    "type": "infection-extension",
    "extra": {
        "infection": {
            "class": "Infection\\TestFramework\\Codeception\\CodeceptionAdapterFactory"
        }
    }
}
  • PHPStan
  • Psalm
  • ...

Infection analyzes composer.lock file and autoregisters all Infection plugins.

Result

Before:

After:

Developing packages locally

# project's composer.json
{
    "require-dev": {
        "infection/core": "^1.0",
        "infection/phpunit-adapter": "^1.0"
    }
}





Developing packages locally

# project's composer.json
{
    "require-dev": {
        "infection/core": "^1.0",
        "infection/phpspec-adapter": "^1.0"
    },
    "repositories": [
        {
            "type": "path", 
            "url": "../path-to-local/phpspec-adapter"
        }
    ]
}
  • You are not developing from vendor folder anymore
  • Other option is to use franzliedke/studio

Developing packages locally

  • $ composer global require franzl/studio
  • $ studio load path/to/infection/phpspec-adapter
  • you don't need to change composer.json
# studio.json
{
    "version": 2,
    "paths": [
        "../infection-phpspec-adapter"
    ]
}

Monorepo or not

Monolith

Multiple Repositories

Monorepo

# infection/core

composer.json
/src
    /Mutator
    /Differ
    ...
    
# infection/codeception-adapter

composer.json
/src
    CodeceptionAdapter.php
    Version.php
    ...
    
# infection/phpunit-adapter

composer.json
/src
    PhpunitAdapter.php
    Version.php
# infection/infection

composer.json
/src
    ...
    /Differ
    /Mutator
    /TestFramework
        /Codeception
        /PhpSpec
        /PhpUnit
# infection/infection

/packages
    /infection-core
        /src
        composer.json
    /infection-codeception-adapter
        /src
        composer.json
    /infection-phpspec-adapter
        /src
        composer.json
    ...

?

Monorepo or not

  • No cross repository changes (Pull Requests)
  • Single place to report issues
  • Discoverability of code is much higher
  • Single SA, lint, build, test and release process
  • Tooling
# infection/infection

/packages
    /infection-core
        /src
        composer.json
    /infection-codeception-adapter
        /src
        composer.json
    /infection-phpspec-adapter
        /src
        composer.json
    ...

Links

Questions?