This series clarifies the Open-Closed Principle with examples. This post describes some approaches that may look like the OCP, but don’t deliver the expected value.

Previous Examples

Previous post in this series covered Implicit data assumptions and externally-owned abstractions. Those are common traps to avoid, but I won’t reproduce them here.

Anti-Example: Abstract Thread

The focus of this post is a hard fail I managed early in my journey to understand the Open-Closed Principle.

This example brings us back to the chat library. Before I tried tags, I tried to use generics to allow for caller-defined custom data.

For example,

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
interface IThread {
  Guid Id;
  string Title;
}

class CampaignThread : IThread{
  Guid CampaignId;
  Guid InfluencerId;
}

interface IThreadClient{
  void UpsertThread(IThread thread);

  T[] GetThreads<T>(Func<T, bool> filter) where T : IThread;
}

This approach was a hot mess. The complexity of the chat library exploded. Generics cascaded through the functions and data. I was allowing the caller to decide what type derivative I passed back to them, so the library couldn’t safely instantiate any of its own types.

Data store operations like saving and querying became a nightmare. I had to store some fields separately so I could query them, but then also had to serialize the whole object in order to restore object state. Querying based on custom fields like CampaignId required translating arbitrary predicates into sql queries. I didn’t know what data might exists or where it might live on the type, so predicates were about my only option.

Key problem

This generic-based approach to custom data tries to invite the caller’s domain into its own. It tries to become flexible as a whole rather than defining contained flexibility on it’s own terms.

Contrast this with the tag-based approach.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class Tag {
  string Key;
  string Value;
}

class Thread {
  Guid Id;
  string Title;
  Tag[] Tags;
}

interface IThreadClient {
  void UpsertThread(Thread thread);
  Thread[] GetThreads(Tags[] tags);
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// In the caller
static class CampaignMessagingTagNames{
  public const string Campaign = "campaignId";
  public const string Influencer = "influencerId";
  public const string Brand = "brandId";

  public static Tag CampaignToTag(Guid campaignId) =>
      new Tag(Campaign, campaignId.ToString());
  public static bool IsCampaignTag(Tag tag) =>
      tag.Key == Campaign;
  public static Guid TagToCampaignId(Tag tag) =>
      new Guid (tag.Value);
}
//...
threadClient.GetThreads(tags: new [BrandToTag(brandId), InfluencerToTag(influencerId)])

The tag based approach contains uncertainty to the tags field. Even the tags field constraints the outer shape of that flexibility. This allows the thread client to deal with tags confidently. Callers haven’t lost any of the desired functionality, and the library is much easier to work with.

Conclusion

Focusing too much on flexibility can undermine the Open-Closed Principle. Remember that the contract for extension belongs to the component offering flexibility. That flexibility should be contained and modeled as part of the component’s domain. Require callers to adapt within your terms, don’t invite the caller’s domain into your own.