Saturday, June 11, 2016

Code Contribution in Continuous Integration Environments

"What branch should I push my changes to?"

Having nudged our Android team into a continuous delivery model, I've been asked some form of this question on more than one occasion as people begin to consider how they'll contribute their changes. It's a great question that I think comes to mind for two reasons:
  1. Perhaps the new changes won't be completed by the time the next release is cut.
  2. Perhaps the app hasn't been regression tested with the new changes.
In both cases the underlying sentiment is that the new changes aren't quite ready for prime time, so there's naturally some hesitation to merge those changes in with the main line of development. This thinking is a by-product of how most teams work in traditional, non-CI environments. Long-running changes are typically segregated in topic/feature branches, completed and tested in isolation, and finally merged into the main line of development.

The shortcoming with this approach in a CI environment is that topic branches typically aren't of the first-class citizen variety wherein any contribution to them will kick off a series of automated tests. By moving code into separate branches we're not only losing that automated feedback loop that tells us if we've broken any existing behavior, but we're also pushing off early integration with others' contributions, which is a common pain point in traditionally maintained projects.

So how can we bridge the gap between changes not ready for wider distribution and having those same changes automatically vetted for accuracy?

Branching by Abstraction

As described by Jez Humble and David Farley in Continuous Delivery, in this development pattern:

  1. A layer of abstraction is first created over the behavior under change. This new layer should initially delegate to whatever currently gives the system its existing behavior. 
  2. With this abstraction in place, development of the new behavior can begin in an area that is unreachable by production flow of execution. 
  3. Once the new behavior is completed, the abstraction layer should be updated to delegate to the new behavior. 
  4. Finally, the old behavior and the abstraction layer are both eligible for removal at the discretion of the maintainers.

As an example, let's assume the following (overly) simple example:

class CalculationResult

class AwesomeApplication {
    fun performSomeComplexCalculation(): CalculationResult {
        // do the leg work behind calculating a
        // complex result before returning it...

        return CalculationResult()
    }
    
    fun main(args: Array<String>) {
        val result = performSomeComplexCalculation()
    }
}

Here we have our AwesomeApplication class with a single function named performSomeComplexCalculation(). The role of this function, as the name implies, is it perform some calculation logic and return a typed result to the caller.

Let's assume now that we've decided to modularize our application by moving the calculation logic out into a separate calculation service. This will likely be a long-running change that entails more than just grabbing the calculation result. We'll need to test our remote call implementation and the various delivery results coming back to us (e.g. successful calls, unsuccessful calls, transient network failures, etc).

As described earlier, we'll start by creating an abstraction layer around invoking our calculation logic:

class CalculationResult

interface Calculator {
    fun calculate(): CalculationResult
}

class InProcessCalculator : Calculator {
 override fun calculate(): CalculationResult {
        // do the leg work behind calculating a
        // complex result before returning it...

        return CalculationResult()
    }
}

class AwesomeApplication constructor(private val calculator: Calculator = InProcessCalculator()) {
    fun main(args: Array<String>) {
        val result = calculator.calculate()
    }
}

As you can see, we've done a bit of refactoring to start. We've created an Calculator interface along with a default implementation named InProcessCalculator, and ported the existing calculation logic into this new implementation class. AwesomeApplication now depends on having a Calculator instance in order to be constructed. (Using some sugary Kotlin magic the default calculator type will be used if left unspecified at object instantiation time).

In short, now we have an abstraction (the interface) we can pivot implementations on. Now begins the work of creating a new Calculator implementation, which will at present have no bearing on the application code flow.

class CalculationResult

interface Calculator {
    fun calculate(): CalculationResult
}

class InProcessCalculator : Calculator {
 override fun calculate(): CalculationResult {
        // do the leg work behind calculating a
        // complex result before returning it...

        return CalculationResult()
    }
}

class RemoteCalculator : Calculator {
 override fun calculate(): CalculationResult {
        // do network calls to get results, etc...

        return CalculationResult()
    }
}

class AwesomeApplication constructor(private val calculator: Calculator = InProcessCalculator()) {
    fun main(args: Array<String>) {
        val result = calculator.calculate()
    }
}

Here we've introduced a new RemoteCalculator type. It isn't used anywhere yet, so the production flow of execution remains intact. We can continue adding changes to this implementation along with supporting tests in order to take advantage of the CI environment - all changes from other collaborators are integrated and tested with each commit/push.

When we are ready to start using the RemoteCalculator type, we can simply update the calls sites where AwesomeApplication is constructed. If , for example, you're using dependency injection, you'll start returning instance(s) of RemoteCalculator for all Calculator bindings. In this example, we'll just change the defaulted parameter value.

class AwesomeApplication constructor(private val calculator: Calculator = RemoteCalculator()) {
    fun main(args: Array<String>) {
        val result = calculator.calculate()
    }
}

Lastly, once we are satisfied with our migration, we can optionally refactor by deprecating (or deleting) InProcessCalculator along with the Calculator abstraction if we so choose. (Although in this particular case, Calculator probably represents a good abstraction point that you probably wouldn't want to remove).

No comments:

Post a Comment