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
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
Instability Value
Stable Dependencies Principle
Instability Value
Stable Dependencies Principle
Instability Value
Stable Dependencies Principle
Instability Value
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
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
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
Abstractness Value
- Cabstract - number of abstract classes and interfaces
- Cconcrete = number of concrete classes
Stable Abstractions Principle
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
- Principles of OOD (Robert Martin)
- Principles of Package Design (Matthias Noback)
- @maks_rafalko
- @infection_php