When getting started with Prequel, our customers define the schemas for different data models that they would like to share with their customers. Those data model configurations (model configs) contain a list of data columns. We make product guarantees around preserving the order that those columns are given to us, as that order is relevant to the way our customers intend to share data.
We recently made some fundamental changes to the way we track those model configs in our product, and we needed to continue to preserve column order. When retrieving model configs, we can order database query results in the ORM, and use Go slices, which preserve element order. Combine these two tools with a handful of unit tests, and we surely should be able to maintain column order without much fuss, right?
Technically, yes those are sufficient. But at Prequel, we’re constantly looking out for “future us”. When you’ve got a lean team building a robust product, you can’t always have every detail and requirement about the product in the front of every engineer’s mind. You have to codify rules about the system. We do this with constraints and triggers in the database all the time, so why can’t we do it with data structures?
The problem with Go slices is that they are mutable. They can be sorted, they can be manually rearranged, they can be appended to, removed from, and they can be sliced. Therefore, a significant risk to column order in our use case is actually ourselves. So we asked, “how can we prevent someone in the future from changing column order in the codebase?”. Enter: the Immutable Slice.
This data structure offers all the read benefits of a slice, but removes any of the mutable functions so that we are unable to change anything about the slice once it is created.
We could write a different version for each type of slice we may want to maintain, but since we’re writing a data structure, this is a prime opportunity to leverage the generics that were introduced in Go 1.18 last year.
A basic implementation of a generic ImmutableSlice starts with a struct definition, a constructor, and a Get implementation.
In the example, you can see we make liberal use of the deepcopy library to ensure that we sever any references to objects passed into or out of the data structure. It helps us prevent unexpected bugs if objects are manipulated after they are composed into an ImmutableSlice.
What if we want to interact with all the items in the ImmutableSlice? We can add an Items function for use with the range keyword:
Again, note the use of deepcopy to prevent any side effects manipulating objects in the slice.
And finally, what about serialization and embedding ImmutableSlice in other structs? We can add functions like MarshalJSON, UnmarshalJSON, Scan, and Hash to handle the remaining core use cases for ImmutableSlice.
Now, rather than constantly reminding every team member that column order is important, we can simply embed ImmutableSlice[Column] into a struct and protect everyone (including “future you”) from breaking a piece of core functionality our customers rely on.
In the Gist linked below, you will find a full implementation of the ImmutableSlice as well as some example usage.