Skip to content

Commit

Permalink
Document the pattern for evolving case classes in a compatible manner (
Browse files Browse the repository at this point in the history
…#2662)

Co-authored-by: Jakub Kozłowski <[email protected]>
Co-authored-by: Julien Richard-Foy <[email protected]>
  • Loading branch information
3 people authored Jan 13, 2023
1 parent 86ce6d8 commit 5694c91
Showing 1 changed file with 135 additions and 0 deletions.
135 changes: 135 additions & 0 deletions _overviews/tutorials/binary-compatibility-for-library-authors.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,141 @@ You can find detailed explanations, runnable examples and tips to maintain binar

Again, we recommend using MiMa to double-check that you have not broken binary compatibility after making changes.

### Changing a case class definition in a backwards-compatible manner

Sometimes, it is desirable to change the definition of a case class (adding and/or removing fields) while still staying backwards-compatible with the existing usage of the case class, i.e. not breaking the so-called _binary compatibility_. The first question you should ask yourself is “do you need a _case_ class?” (as opposed to a regular class, which can be easier to evolve in a binary compatible way). A good reason for using a case class is when you need a structural implementation of `equals` and `hashCode`.

To achieve that, follow this pattern:
* make the primary constructor private (this makes the `copy` method of the class private as well)
* define a private `unapply` function in the companion object (note that by doing that the case class loses the ability to be used as an extractor in match expressions)
* for all the fields, define `withXXX` methods on the case class that create a new instance with the respective field changed (you can use the private `copy` method to implement them)
* create a public constructor by defining an `apply` method in the companion object (it can use the private constructor)

Example:

{% tabs case_class_compat_1 %}
{% tab 'Scala 3 Only' %}

```scala
// Mark the primary constructor as private
case class Person private (name: String, age: Int):
// Create withXxx methods for every field, implemented by using the copy method
def withName(name: String): Person = copy(name = name)
def withAge(age: Int): Person = copy(age = age)

object Person:
// Create a public constructor (which uses the primary constructor)
def apply(name: String, age: Int): Person = new Person(name, age)
// Make the extractor private
private def unapply(p: Person) = p
```
{% endtab %}
{% endtabs %}
This class can be published in a library and used as follows:

{% tabs case_class_compat_2 %}
{% tab 'Scala 2 and 3' %}
~~~ scala
// Create a new instance
val alice = Person("Alice", 42)
// Transform an instance
println(alice.withAge(alice.age + 1)) // Person(Alice, 43)
~~~
{% endtab %}
{% endtabs %}

If you try to use `Person` as an extractor in a match expression, it will fail with a message like “method unapply cannot be accessed as a member of Person.type”. Instead, you can use it as a typed pattern:

{% tabs case_class_compat_3 class=tabs-scala-version %}
{% tab 'Scala 2' %}
~~~ scala
alice match {
case person: Person => person.name
}
~~~
{% endtab %}
{% tab 'Scala 3' %}
~~~ scala
alice match
case person: Person => person.name
~~~
{% endtab %}
{% endtabs %}

Later in time, you can amend the original case class definition to, say, add an optional `address` field. You
* add a new field `address` and a custom `withAddress` method,
* update the public `apply` method in the companion object to initialize all the fields,
* tell MiMa to [ignore](https://github.com/lightbend/mima#filtering-binary-incompatibilities) changes to the class constructor. This step is necessary because MiMa does not yet ignore changes in private class constructor signatures (see [#738](https://github.com/lightbend/mima/issues/738)).

{% tabs case_class_compat_4 %}
{% tab 'Scala 3 Only' %}
```scala
case class Person private (name: String, age: Int, address: Option[String]):
...
def withAddress(address: Option[String]) = copy(address = address)

object Person:
// Update the public constructor to also initialize the address field
def apply(name: String, age: Int): Person = new Person(name, age, None)
```
{% endtab %}
{% endtabs %}

And, in your build definition:

{% tabs case_class_compat_5 %}
{% tab 'sbt' %}
~~~ scala
import com.typesafe.tools.mima.core._
mimaBinaryIssueFilters += ProblemFilters.exclude[DirectMissingMethodProblem]("Person.this")
~~~
{% endtab %}
{% endtabs %}

Otherwise, MiMa would fail with an error like “method this(java.lang.String,Int)Unit in class Person does not have a correspondent in current version”.

> Note that an alternative solution, instead of adding a MiMa exclusion filter, consists of adding back the previous
> constructor signatures as secondary constructors:
> ~~~ scala
> case class Person private (name: String, age: Int, address: Option[String]):
> ...
> // Add back the former primary constructor signature
> private[Person] def this(name: String, age: Int) = this(name, age, None)
> ~~~
The original users can use the case class `Person` as before, all the methods that existed before are present unmodified after this change, thus the compatibility with the existing usage is maintained.
The new field `address` can be used as follows:
{% tabs case_class_compat_6 %}
{% tab 'Scala 2 and 3' %}
~~~ scala
// The public constructor sets the address to None by default.
// To set the address, we call withAddress:
val bob = Person("Bob", 21).withAddress(Some("Atlantic ocean"))
println(bob.address)
~~~
{% endtab %}
{% endtabs %}
A regular case class not following this pattern would break its usage, because by adding a new field changes some methods (which could be used by somebody else), for example `copy` or the constructor itself.
Optionally, you can also add overloads of the `apply` method in the companion object to initialize more fields
in one call. In our example, we can add an overload that also initializes the `address` field:
{% tabs case_class_compat_7 %}
{% tab 'Scala 3 Only' %}
~~~ scala
object Person:
// Original public constructor
def apply(name: String, age: Int): Person = new Person(name, age, None)
// Additional constructor that also sets the address
def apply(name: String, age: Int, address: String): Person =
new Person(name, age, Some(address))
~~~
{% endtab %}
{% endtabs %}
## Versioning Scheme - Communicating compatibility breakages
Library authors use versioning schemes to communicate compatibility guarantees between library releases to their users. Versioning schemes like [Semantic Versioning](https://semver.org/) (SemVer) allow
Expand Down

0 comments on commit 5694c91

Please sign in to comment.