Managing backward compatibility in Stickee
Stickee is a web application that we are currently building. Its main goal is to be an easy self-hosted solution to share and store text temporarily in a secure way.
Since the application is in early development, its structure is still very volatile and everything can change, like the database schema. However, we want to make sure that updating the application doesn't break anything and doesn't require any manual intervention.
When we design new features and improvements, we do it with backward compatibility in mind. If there is some ugly design that we need to make in order to maintain this compatibility, we add it into a list to change in a future breaking change release.
The previous behaviour
When a note is created, its link has the following structure https://stickee.siesta.cat/notes/<note-id>
. This <note-id>
is unique for each note, and is a fixed-length string composed from any uppercase letters, lowercase letters, or digits.
In our domain model, we have a Note
class with a value object NoteId
. This NoteId
implements the specification we designed for the note ids, which is the following:
- It must have a specified fixed length.
- It must be ASCII.
- It must contain only alphanumeric characters.
The new breaking changes
In the last version, we applied a change to make that the ids generated for the notes must have at least one digit in them. This way, the <note-id>
will not have conflicts with any endpoints we could have, like https://stickee.siesta.cat/notes/create
. We also added a new constraint when creating a NoteId
:
- It must contain at least one digit.
However, there is a problem with that.
When a note is retrieved, the NoteService
maps the NoteEntity
obtained from the NoteRepository
to the Note
class:
public Optional<Note> get(String id) {
return noteRepository.findById(id).map(entity -> mapper.toModel(entity));
}
Now, imagine we have already a bunch of notes created in previous versions, with at least one with an id that follows the previous specification, that doesn't have any digits. When the NoteService
does the mapping, it will fail because that id no longer follows the specification.
So... What should we do? Well, we have two options:
Do nothing to tackle this so some previous notes will no longer be visible. They will be auto-deleted after a few days anyways.
Be able to retrieve notes with ids that don't follow the specification but are already in the database.
If we care about the people that is using our software, we should make an effort to maintain this backward compatibility.
Implementing the solution
First, we write a test with an id that doesn't follow the specifications, then we check that indeed doesn't follow them, and also that the service retrieves the note we stored earlier. This last check will fail for now.
// This is to avoid breaking compatibility with older versions if the
// specification of the note id changes
@Test
void shouldGetAlreadyExistingNoteWithInvalidId() {
var invalidId = "invalidId";
var entity = new NoteEntity(invalidId, "text", LocalDateTime.now(), TextCipher.PLAIN);
noteRepository.save(entity);
assertThrows(IllegalArgumentException.class, () -> new NoteId(invalidId));
assertEquals(entity.getText(), noteService.get(invalidId).get().getText());
}
Next, we add a new static method in the NoteId
class, allowing us to create a NoteId
without doing any validation. Its use is exclusive for mapping database entities to domain model instances. We do some refactor to achieve this:
NoteId.java
:
Before:
public NoteId(String id) {
if (!isValid(id)) {
throw new IllegalArgumentException("id doesn't follow the specification");
}
this.id = id;
}
After:
public NoteId(String id) {
this(id, true);
}
private NoteId(String id, boolean shouldValidate) {
if (shouldValidate && !isValid(id)) {
throw new IllegalArgumentException("id doesn't follow the specification");
}
this.id = id;
}
/**
* Allows the creation of note ids without validation, used to
* maintain compatibility with previous versions of the application.
*
* The use of this method should be strictly to maintain this compatibility,
* and will be removed in future updates.
*
* @param id the id of the note, that can be invalid.
* @return an unvalidated NoteId.
*/
public static NoteId createWithoutValidation(String id) {
var noteId = new NoteId(id, false);
return noteId;
}
Finally we refactor the mapper to use this new method:
NoteEntityMapper.java
:
Before:
public Note toModel(NoteEntity entity) {
var decryptedText = switch (entity.getTextCipher()) {
case PLAIN -> entity.getText();
case AES256 -> encryptor.decrypt(entity.getText());
};
return new Note(Optional.ofNullable(new NoteId(entity.getId())), decryptedText,
new NoteTimestamp(entity.getCreationTimestamp()));
}
After:
public Note toModel(NoteEntity entity) {
var decryptedText = switch (entity.getTextCipher()) {
case PLAIN -> entity.getText();
case AES256 -> encryptor.decrypt(entity.getText());
};
return new Note(Optional.ofNullable(NoteId.createWithoutValidation(entity.getId())), decryptedText,
new NoteTimestamp(entity.getCreationTimestamp()));
}
With these changes, all of our tests pass, including the last one we made to maintain the backward compatibility. We can push and deploy and check that we don't have any problems getting notes with invalid ids. Yay!