Semantic versioning and contracts in source code

VilniusPHP 0x3C

I'll be talking about...

  • Libraries
  • Why do we need versioning at all
  • What's semantic versioning and contracts (API)
  • Avoiding dependency hell

Marius BalĨytis

Lots of features

payment gateway transfers to banks to other systems paying for utilities top up via transfer top up via various partners mobile apps Paysera VISA currency conversion exchange rates from various sources user identification anti-money laundering systems two factor authorisation referral system premium SMS APIs for partners login with Paysera cash operations via partners POS solutions Paysera tickets

Divide and conquer!

Lots of common code

  • framework
  • REST API, logging etc.
  • common integrations
  • common helper functionality

Let's take an example!

We have an eshop from 4 microservices

  • Users (authentication)
  • Catalog
  • Ordering
  • Basket

We need to send email

  • After registration (Users service)
  • After successful order (Ordering service)
  • We have mailer library for sending emails

Example mailer library code


namespace VilniusPhp\Mailer;

class Mailer
{
    public function __construct(array $options)
    {
        // ...
    }

    public function sendEmail($email, $subject, $body)
    {
        // ...
    }
}

mailer usage in registration


use VilniusPhp\Mailer;
$mailer = new Mailer(['host' => '127.0.0.1:25']);
$mailer->sendEmail(
    '[email protected]',
    'Welcome to my site!',
    $templating->render('email_content.html.twig')
);
					

mailer usage after successful order


use VilniusPhp\Mailer;
$mailer = new Mailer(['host' => '127.0.0.1:25']);
$mailer->sendEmail(
    '[email protected]',
    'Order successful!',
    $templating->render('order.html.twig', ['order' => $order])
);
					

How can we share mailer code?

Option #1: copy & paste

Monolithic vs Microservices

Option #1 cons

  • Copies evolves separately
  • Hard to choose if you want to copy again
  • Hard to merge
  • Really hard to update

Option #2: composer library

composer.json inside mailer


{
    "name": "vilnius-php/mailer"
}
					

composer.json inside our services


{
    "name": "...",
    "require": {
        "vilnius-php/mailer": "*"
    }
}
					

Option #1 cons vs Option #2

  • Copies evolves separately Copies evolves separately
  • Hard to choose if you want to copy again Hard to choose if you want to copy again
  • Hard to merge Hard to merge
  • Really hard to update Really

Original mailer library code


                        namespace VilniusPhp\Mailer;

                        class Mailer
                        {
                            public function __construct(array $options)
                            {
                                // ...
                            }

                            public function sendEmail($email, $subject, $body)
                            {
                                // ...
                            }
                        }
                    

Changed mailer library code


                        namespace VilniusPhp\Mailer;

                        class Mailer
                        {
                            public function __construct(array $options)
                            {
                                // ...
                            }

                            public function sendEmail(array $emails, $subject, $body)
                            {
                                // ...
                            }
                        }
                    

Next step: update! There are 2 ways

  • Update everywhere
    • 2 places for now
    • can be tens or more
    • not always possible
  • Update only where needed
    • What about the other place?

So, you have to choose

  • Change every usage when changing the library
  • Check for all changes inside the library when updating

Semantic versioning to the rescue!

From semver.org

Given a version number MAJOR.MINOR.PATCH, increment the:

  1. MAJOR version when you make incompatible API changes,
  2. MINOR version when you add functionality in a backwards-compatible manner, and
  3. PATCH version when you make backwards-compatible bug fixes.

What's backwards incompatible library change?

When you possibly break something when it's updated

Given that only API is used (aka contract)

What's an API / contract?

The things you're allowed to use from the library

First rule from semver

Software using Semantic Versioning MUST declare a public API. This API could be declared in the code itself or exist strictly in documentation. However it is done, it should be precise and comprehensive.

API in Symfony

  • You can create, typehint, call public methods on classes
  • You can extend and call protected methods or access protected properties, override them
  • You cannot extend and add more methods or properties
  • You cannot access private things (via reflection)
  • You cannot use anything @internal

It's not that you cannot do that, it's just that it can break when you update the framework

Changes inside mailer


                        git tag 2.0.0
                        git push --tags
                    

composer.json inside User service


{
    "name": "...",
    "require": {
        "vilnius-php/mailer": "^2.0.0"
    }
}
					

composer.json inside Orders service


{
    "name": "...",
    "require": {
        "vilnius-php/mailer": "^1.0.0"
    }
}
					

UPGRADE.md inside mailer library


                        # Upgrade from 1.0 to 2.0

                        First argument in method `sendEmail` was changed from string
                        to array.

                        Previous:
                        ```
                        $mailer->sendEmail($email, $subject, $body);
                        ```

                        Updated:
                        ```
                        $mailer->sendEmail(['Name Surname' => $email], $subject, $body);
                        ```
					

Why the MINOR bumps are needed?

vilnius-php/mailer requirements:


                        {
                            // ...
                            "require": {
                                "vilnius-php/email-validator": "^1.0"
                            }
                        }
					

Project requirements:


                        {
                            // ...
                            "require": {
                                "vilnius-php/mailer": "^1.0",
                                "vilnius-php/email-validator": "^1.0"
                            }
                        }
					

                        class EmailValidator
                        {
                            public function validateEmail($email)
                            {
                                // ...
                            }

                            // this was added in 1.1 version:
                            public function validateEmailList(array $emails)
                            {
                                // ...
                            }
                        }
					

                        namespace VilniusPhp\Mailer;

                        class Mailer
                        {
                            // ...

                            public function sendEmail(array $emails, $subject, $body)
                            {
                                // this call is added in 2.0.1
                                $this->emailValidator->validateEmailList($emails);

                                // ...
                            }
                        }
					

                        composer update vilnius-php/mailer
					

Is everything all right now?

Nope - fatal

Fix inside vilnius-php/mailer


                        {
                            // ...
                            "require": {
                                "vilnius-php/email-validator": "^1.1"
                            }
                        }
					

                        composer update vilnius-php/mailer
					

Is everything all right now?

Nope - update fails


                        composer update vilnius-php/mailer --with-dependencies
					

Cost of changing the contract

  • Libraries tend to be used (hopefully)
  • The more it's used, the more API change can have ripples
G Service 1 Service 1 Lib 1 Lib 1 Service 1->Lib 1 ^1.0 Lib 2 Lib 2 Service 1->Lib 2 ^1.0 Lib 3 Lib 3 Service 1->Lib 3 ^1.0 Lib 5 Lib 5 Lib 1->Lib 5 ^1.0 Lib 2->Lib 5 ^1.0 Lib 3->Lib 5 ^1.0 Service 2 Service 2 Service 2->Lib 3 ^1.0 Lib 4 Lib 4 Service 2->Lib 4 ^1.0 Lib 4->Lib 5 ^1.0
G Service 1 Service 1 Lib 1 Lib 1 Service 1->Lib 1 ^1.0 Lib 2 Lib 2 Service 1->Lib 2 ^1.0 Lib 3 Lib 3 Service 1->Lib 3 ^1.0 Lib 5 Lib 5 Lib 1->Lib 5 ^2.0 Lib 2->Lib 5 ^1.0 Lib 3->Lib 5 ^1.0 Service 2 Service 2 Service 2->Lib 3 ^1.0 Lib 4 Lib 4 Service 2->Lib 4 ^1.0 Lib 4->Lib 5 ^1.0
G Service 1 Service 1 Lib 1 Lib 1 Service 1->Lib 1 ^1.0 Lib 2 Lib 2 Service 1->Lib 2 ^1.0 Lib 3 Lib 3 Service 1->Lib 3 ^1.0 Lib 5 Lib 5 Lib 1->Lib 5 ^2.0 Lib 2->Lib 5 ^2.0 Lib 3->Lib 5 ^2.0 Service 2 Service 2 Service 2->Lib 3 ^1.0 Lib 4 Lib 4 Service 2->Lib 4 ^1.0 Lib 4->Lib 5 ^1.0
G Service 1 Service 1 Lib 1 Lib 1 Service 1->Lib 1 ^1.0 Lib 2 Lib 2 Service 1->Lib 2 ^1.0 Lib 3 Lib 3 Service 1->Lib 3 ^1.0 Lib 5 Lib 5 Lib 1->Lib 5 ^2.0 Lib 2->Lib 5 ^2.0 Lib 3->Lib 5 ^1.0|^2.0 Service 2 Service 2 Service 2->Lib 3 ^1.0 Lib 4 Lib 4 Service 2->Lib 4 ^1.0 Lib 4->Lib 5 ^1.0

Avoid MAJOR changes!

Avoiding MAJOR changes

  • release MAJOR version not so often
  • narrow the contract

Postponing MAJOR changes

  • add new features
  • deprecate legacy stuff, but support it
  • when the time comes, remove deprecations and bump MAJOR

                        class Mailer
                        {
                            // ...
                            public function sendEmail($email, $subject, $body)
                            {
                                // ...
                            }
                        }
					

                        class Mailer
                        {
                            // ...
                            public function sendEmail($emails, $subject, $body)
                            {
                                if (is_string($emails)) {
                                    $emails = [$emails => $emails];
                                    @trigger_error(
                                        'Email as string will not be supported in 2.0',
                                        E_USER_DEPRECATED
                                    );
                                }
                                // ...
                            }
                        }
					

                        class Mailer
                        {
                            // ...
                            public function sendEmail(array $emails, $subject, $body)
                            {
                                // ...
                            }
                        }
					

                        class Mailer
                        {
                            /**
                             * @deprecated this will be removed in 2.0
                             * @use sendEmailToMany
                             */
                            public function sendEmail($email, $subject, $body)
                            {
                                // ...
                            }

                            public function sendEmailToMany(array $emails, $subject, $body)
                            {
                                // ...
                            }
                        }
					

Narrowing the contract

  • Strictly define allowed usages
    • You developed a CMS plugin
    • It uses a database
    • Can other code make assumptions about data stored there?
  • Avoid non-private properties
  • Avoid non-private methods
  • Define which classes are @internal
  • In some frameworks - only interfaces and services

                        interface MailerInterface
                        {
                            public function sendEmail($email, $subject, $body);
                        }

                        /**
                         * @internal
                         */
                        class Mailer implements MailerInterface
                        {
                            // ...
                        }
					

                        /** @var MailerInterface $mailer */
                        $mailer = $container->get('vilnius_php.mailer');
					

Let's summarize!

  • Semver let's to change library without updating usages everywhere
  • Avoid MAJOR bumps
  • Think about the contract, be conservative