Releases Without Fear: Backward Compatibility

Principles to prevent old code from breaking

Avoid modifying existing interfaces if possible.

Instead of changing the parameters or logic of old functions, add new methods or extensions while leaving the old implementation intact.

Example in Go:

// старый метод:
func GetUser(id int) (User, error) {
    // получение пользователя
}

// новый метод, расширяющий старую функциональность:
func GetUserV2(id int, includeInactive bool) (User, error) {
    // получение пользователя с дополнительным параметром
}

This allows old calls to continue to work, while new users can take advantage of additional features.

Use semantic versioning

Semantic versioning helps to mark changes in the code. The basic rule: changes that break backward compatibility require an increase in the first digit of the version. Minor changes and bug fixes should not break compatibility with previous versions.

1.0.0 → 1.1.0 → 1.1.1 (совместимы)
2.0.0 (несовместимая версия)

Versioning APIs and libraries

To avoid breaking your customers, use a multi-version API approach. When you release a new version, keep the old version available for those who are not yet ready to migrate.

REST API example:

GET /api/v1/users
GET /api/v2/users

Don't rush to remove deprecated functions. Declare them instead deprecatedgiving users the ability to migrate to new versions with a pre-defined support period for the old version.

Strategies for gradual implementation of changes

Feature toggles

Feature toggles are a feature management technique where new features can be enabled or disabled via configuration, without requiring code changes or redeployment of the application.

Feature toggles are implemented as logical conditions in your code that determine whether a new feature should be enabled for a specific user or group of users. For example, at the application level, this might look like a simple conditional statement:

Example on Java:

if (FeatureToggle.isEnabled("new_feature")) {
    // включение новой функциональности
} else {
    // выполнение старого кода
}

Feature toggles allow you to:

  • Enable/disable features in real time without deploying a new version.

  • Restricting access to new features for specific groups of users is, by the way, good for the same A/B test.

  • Avoid large releases with the risk of breakage by breaking the work into smaller iterations.

Types of Feature toggles:

  • Release toggles: used to release features that are not yet ready for mass use, but are in development. The feature may be enabled later.

  • Ops toggles: used for operational management of features in production, for example, to quickly disable functionality in case of problems.

  • Experiment toggles: used to test functions on specific groups of users.

Dynamic control Feature Toggles via configuration allow you to toggle functionality without making changes to the codebase or redeploying the application.

Example of implementation:

Let's create a configuration file config.yamlwhere we will manage the state of the feature:

feature_toggles:
  new_feature: true
  experimental_feature: false

Let's write code to dynamically load the configuration and use it Feature Toggles:

package main

import (
    "fmt"
    "gopkg.in/yaml.v2"
    "io/ioutil"
)

type Config struct {
    FeatureToggles map[string]bool `yaml:"feature_toggles"`
}

func loadConfig(filename string) (*Config, error) {
    data, err := ioutil.ReadFile(filename)
    if err != nil {
        return nil, err
    }

    var config Config
    err = yaml.Unmarshal(data, &config)
    if err != nil {
        return nil, err
    }

    return &config, nil
}

func main() {
    config, err := loadConfig("config.yaml")
    if err != nil {
        panic(err)
    }

    if config.FeatureToggles["new_feature"] {
        fmt.Println("New feature is enabled")
        // Активируем новую функциональность
    } else {
        fmt.Println("Old feature is running")
        // Оставляем старую функциональность
    }
}

Now, changing the state of a feature in a file config.yamlyou can turn it on or off without having to change the code or restart the application.

Blue-green deployment

Blue-green deployment — is a deployment strategy where there are two versions of the application: one blue, blue – working in production, and the second green one, green — a new version that is being prepared for release. At the time of deployment, traffic switches from the current blue version to the new green one.

Deployment process:

  1. Blue Wednesday — the current version of the application running in production.

  2. Green environment — a new version that has been deployed in parallel and is being prepared for testing.

  3. After successful testing on the green environment, user traffic is redirected to it. If everything is OK with the new version, the green environment becomes the main one.

  4. Rollback: If problems are found, traffic can be quickly reverted to the old blue version.

Let's say the application is running on a server with version 1.0 (blue environment). You can deploy version 2.0 on a new server and check its functionality. If everything works correctly, then we redirect all traffic from the blue to the green environment, without any downtime.

To implement this strategy, they use Kubernetes, Spinnakeror Terraform.

Canary releases

Canary release — is a strategy where a new version of an application is rolled out to only a small portion of users. If the new version is successful, the percentage of users gradually increases and it becomes available to everyone.

The name “canary releases” comes from the practice of using canaries in mines to warn of harmful gases – if the canary dies, the miners know there is danger (poor canaries).

Miner with a Canary

Miner with a Canary

Similarly, we have canary releases that help us identify issues on a small number of users before they affect everyone.

Deployment process:

  1. First phase: a new version is rolled out to a small portion of the audience, say 5%. During this time, the team monitors metrics, performance, and user feedback.

  2. Increase traffic: If no major problems arise, the new version is gradually distributed to a larger proportion of users, for example 25%, 50%, and so on.

  3. Full release: if everything goes smoothly at all stages, the new version becomes the main one for all users.

Example on Kubernetes with Istio:

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
spec:
  hosts:
  - my-app
  http:
  - route:
    - destination:
        host: my-app
        subset: v2
      weight: 5   # 5% пользователей получают новую версию
    - destination:
        host: my-app
        subset: v1
      weight: 95  # 95% пользователей остаются на старой версии

The main risk with such releases is that if a bug occurs in a new version, it can affect even a small audience. Therefore, it is important to monitor key metrics and automatically roll back the release if something goes wrong.

Types of tests to check backward compatibility

Regression tests

Regression testing is the process of re-running old tests on a changed system to verify that recent changes have not broken existing functionality.

The main goals of regression tests are:

  • Checking that new features do not affect old functionality.

  • Make sure that fixing one bug does not lead to the appearance of another.

Example of a test on JUnit (Java):

@Test
public void testOldFunctionality() {
    User user = userService.getUserById(1);
    assertNotNull(user);
    assertEquals("John", user.getName());
}

This way you can make sure that old features like getUserByIdcontinue to work correctly after making changes to the code.

When to use:

  • After each change in the code.

  • When new features are added or bugs are fixed.

  • Before each release.

Regression tests are suitable for automation because they check existing functionality and should pass without changes every time. Use them as part of a CI/CD pipeline.

Contract testing

Contract testing verifies that changes to one service do not violate agreements between services.

Example of application Pact for contract testing:

@Pact(provider = "UserProvider", consumer = "UserConsumer")
public RequestResponsePact createPact(PactDslWithProvider builder) {
    return builder
        .given("User with ID 1 exists")
        .uponReceiving("A request for User ID 1")
        .path("/users/1")
        .willRespondWith()
        .status(200)
        .body(new PactDslJsonBody()
              .integerType("id", 1)
              .stringType("name", "John"))
        .toPact();
}

This test checks that the contract between the client and the server to retrieve a user by ID remains valid after changes.

When to use:

  • In microservice architectures where API compatibility is required.

  • When API or microservices change.

  • When changing contracts between services.

Contract tests can be run within CI/CD pipelines.


How to create your own CI/CD pipeline using Tekton + Kubernetes? You will find out on September 19 at an open lesson, which will be held as part of the course “DevOps practices and tools”. If the topic is relevant to you – sign up for the lesson using the link.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *