Opaque Types
One design pattern that I think gets overlooked a lot and is criminimally under used is the Opaque Types pattern. It’s great for any time that it’s really important to hide the implementation details or the contents of a type. It can also make it easier to come up with an abstraction that isn’t overly complicated to implement.
Let’s look first at a basic implementation of such a type and then we’ll look at how they can actually be used in practice.
Implementation⌗
I think C is really helpful for illustrating the basics of this but you can do it in most any language.
Say we had some header file called my_lib.h
with the following
declarations.
struct Writer;
int write_bytes(struct Writer *writer, char *data);
When someone wants to consume this header they can use the Writer
type and the write_bytes
function but they can’t see any internal
data on the Writer
since it doesn’t expose any properties. This is
important because all properties of a struct
are public. And because
no details are exposed, it’s really easy to change the implementation
of such a type without breaking consumers (short of changing behaviors
but that’s a different story). It also means that consumers can’t go
digging in places you don’t want them to which can be super important.
Now that we have the basic idea, let’s look at how they can be used in the real world.
Usage⌗
Hiding implementation details⌗
An easy example of hiding the specific implementation details that also relates to the code above is the FILE type in C. This type holds a file handle to some open file. It’s important that the exact implementation is hidden since there’s all sorts of ways you could implement a file system and all of them would have different data requirements.
Some file systems might require an inode reference or a URL to some S3 object. By not requiring the type to expose any of that information makes it easier to swap things out.
Hiding the concrete type⌗
Some times you don’t want the actual data type to be known purely because you don’t want people to build a dependence on things working one specific way.
For example, maybe right now the ID’s of some object in your data model are all integers using the auto-increment feature in a relational database but you know that long term you want to switch to some other database type or you want to use some other data type which is more cluster friendly such as a UUID. To make sure that consumers aren’t writing their code around the ID always being an integer.
In Go I would do that like so:
type UserID int
// and eventually...
type UserID string
Now the exact type of UserID
is hidden and it’s harder to rely upon
without a lot of extra work which hopefully would clue in a consumer
that they’re going down the wrong path.
If you really want to bury the information even further you can wrap
the values in a struct
.
type UserID struct {
value string
}
Hiding values⌗
This is by far my favorite reason to use this pattern. Some times you want to hide (or make really difficult to use) the actual data represented by the type. Now you might be asking why we would ever want to take a perfectly good value and obfuscate it. The answer is security.
Say we created some user type that we want to be able to pass around our backend. We tried to be smart and made sure to omit things like the password or API tokens but there’s still a problem. There’s a bunch of fields which contain PII.
type User struct {
ID UserID
Username string
Email string
CreatedOn time.Time
DeactivatedOn *time.Time
}
If someone were to innocently print out a User
instance in a log
message we would now be logging PII about the user which is a good
way to get in a lot of trouble.
Thankfully we can make some of those fields opaque types and add utility methods to access the values when we need to.
type OpaqueString struct {
value string
}
func NewOpaqueString(value string) OpaqueString {
return OpaqueString{
value: value,
}
}
func (opaque OpaqueString) DangerouslyGetValue() string {
return opaque.value
}
func (opaque OpaqueString) String() string {
return "**omitted**"
}
func (opaque OpaqueString) MarshalJSON() ([]byte, error) {
return json.Marshal(opaque.String())
}
type User struct {
ID UserID
Username OpaqueString
Email OpaqueString
CreatedOn time.Time
DeactivatedOn *time.Time
}
Now if someone tries to use the opaque value as a string or to serialize it as JSON the value will be hidden.
If someone really needs the value they can still get it but it’s way more obvious you’re doing something risky.
sendEmail(user.Email.DangerouslyGetValue())
This doesn’t 100% prevent people from doing stupid things but it should cover most cases and make it really easy to catch with automated tooling or manually in a code review.
doNotDoThis := user.Email.DangerouslyGetValue()