Design Pattern - The Special Case

What is a Special Case? #

Primarily used in object oriented programming, a Special Case is a subclass of an interface designed to resolve an edge case.

The naming of it, like most programming, invites a lot of confusion. If you are like me and didn't come up in OOP environments, you might think it was a general term rather than a thing. For that reason, I am intentionally capitalizing it throughout this post.

It's most easily understood through an example scenario: An e-commerce application needs to update an Order, such as marking it as fulfilled or cancelled.

Handling edge cases #

What happens when the order doesn't exist, or is in a state where it can't perform the operation?

Typically, one of two things:

  • Exceptions are thrown, i.e. an OrderNotFoundException
  • Null is returned to indicate the resource doesn't exist.

Neither of these options are ideal in most cases. Both error handing and null checks increase complexity and potential for missed scenarios. A smarter person than I might go into cyclomatic complexity with mathematical calculations and a bit of arm waving.

For our sake, the concern is that the the order boundary is no longer self contained: the caller is now responsible for handling the edge cases, not the Order. How do we mitigate that as much as possible?

Using a Special Case #

Special Cases are a nifty way to keep management of the edge cases within the ownership of the Order.

Here is a breakdown of what it could look like:

Step 1: Creating the Order #

You begin with your standard Order model, with it's methods to handle updating it's own data like below (simplified for sake of space):


// models/Order.ts
interface IOrder {
// ... methods and properties typical of an order
cancel(): Promise<void>;
fulfill(): Promise<void>;
}

class Order implements IOrder {
// ... methods and properties typical of an order

async fulfill() {
// update the order's data
// trigger any secondary actions
}

async cancel() {
// update the order's data
// trigger any secondary actions
}
}

Step 2: Creating the OrderFactory #

You create a OrderFactory to handle the resolution of an Order using some standard methods. You'll notice that they always resolve an IOrder interface. The importance of that will become clear soon:

// OrderFactory.ts
interface IOrderFactory {
getById(id: number): Promise<IOrder>;
getByOrderNumber(id: string): Promise<IOrder>;
}

class OrderFactory implements IOrderFactory {
async getById(id: number): IOrder {
// assume everything is perfect
// ...actually resolve by id
return new Order();
}

async getByOrderNumber(number: string): IOrder {
// assume everything is perfect
// ...actually resolve by number
return new Order();
}
}

The caller might look like this:

async function completeOrder(orderId: number) {
const order = await OrderFactory.getById(orderId);
await order.fulfill();
}

Step 3: Handling a missing order #

At this point you might think that we could start handling edge cases directly in our Order class by doing additional checks there and having nullable properties.

Instead, we are going to create a NullOrder:

class NullOrder implements IOrder {
// ... methods and properties typical of an order
// but they hold values or run logic that matches the cases

async fulfill() {
// logger.log a warning or error that we hit a weird case
// handle any cleanup or secondary actions unique to this case
}

async cancel() {
// logger.log a warning or error that we hit a weird case
// handle any cleanup or secondary actions unique to this case
}
}

Step 4: Tying it together #

Hopefully you're starting to see some benefits here. The OrderFactory maintains it's interface but conditionally returns the appropriate subclass instance. Here we do see conditionals and null checks, but they are isolated and within the Order domain.


class OrderFactory implements IOrderFactory {
async getById(id: number): IOrder {
// ...
const orderDto = OrderRepository.findById(id);
if(orderDto !== null) {
return new Order(orderDto);
} else {
// injects default values to `super`
return new MissingOrder();
}
}

}

There are a lot of implementation details and decisions here, try not to focus on the details of this flow too much.

The important part is our caller:

async function completeOrder(orderId: number) {
const order = await OrderFactory.getById(orderId);
await order.fulfill();
}

It hasn't changed, because our interface hasn't changed. We are actively avoiding null checks and try/catches in this scenario. We also allowed for special case logic in the event of a MissingOrder

Considerations #

At this point you might be seeing some plot holes. I will walk through what I see in terms of issues, but most are just extensions of the pattern rather than reasons not to use it at all.

The caller might need to know if everything is ok #

It's common to be working synchronously, and that the caller actually needs to know that the order was fulfilled before moving on.

We might need to return an API response to a client, or even to decide whether to continue in a process. Should we try to send an 'order fulfilled' email even though nothing of the sort occurred? Depending on the structure of your system, the caller might need something to work with.

If you're only dealing with a few possible cases, you might do something like:

async function completeOrder(orderId: number) {
const order = await OrderFactory.getById(orderId);
await order.fulfill();

if(order instanceof NullOrder) {
//... handle accordingly
}
}

Martin Fowler also offers the suggestion of a dedicated method to check, such as isNull in his book Patterns of Enterprise Application Architecture:

If we wanted something more general that allows for other cases than just NullOrder, we could use isValid:

async function completeOrder(orderId: number) {
const order = await OrderFactory.getById(orderId);
await order.fulfill();

if(order.isValid()) {
//... handle accordingly
}
}

Regardless of the approach we are still left with a benefit: the caller has a lot less work to do and shouldn't concern itself with any logic that is now internal to the IOrder interface.

We can also avoid repeated conditionals or switch statements by using the second method rather than iterating through instanceof cases.

Sometimes we want to fail explicitly #

The Special Case is not always appropriate, especially for scenarios that indicate the process should halt completely. In these situations an Exception is appropriate and should be used.

Sometimes you might even apply a mixed approach where the Special Case class throws exceptions on certain methods if the caller attempts them, but allows others to be called without issue.

An naive example of how you could use a Special Case this way would be if you had a specific case where an Order is on locked and shouldn't be processed:

class LockedOrder implements IOrder {

    fulfill() {
        // Wups! Can't do that
        throw new Error("Order is on hold and cannot be fulfilled.");
    }
}

Abusing Special Cases #

As you might have noted, this approach is directly related to mocking or stubbing. Once this mental model is in place, it can be used to solve a variety of interesting problems. As will all patterns, it can also be overused.

The previous example toes the water of this problem as you can quickly find all sorts of possible "special cases" that could use a Special Case. Following this one guideline will help avoid overuse:

Special Cases should implement an interface, not inherit from a class.

If you're tempted to inherit, you might have a composition problem.

Another Example #

Data consistency and defaults #

Say that you are building commenting functionality on a social platform, and want to create both a Comment and it's corresponding User. You might associate them through a UserComment relationship.

You've been directed that comments can be posted either anonymously or as a logged in user. You might want to avoid the data inconsistencies and edge case handling that might occur through allowing a UserComment to be nullable.

To make things more cohesive, you create the Special Case AnonymousUser that uses the existing data structures to inject static anonymous user info, and perhaps even use a common AuthorId for data analysis.


interface IUser { // ... }
class AnonymousUser implements IUser { //... }

class UserFactory {
async getById(id: number | null): IUser {
//...
if(id === null) {
return new AnonymousUser();
}
//...
}
}

class Comment {
setUser(userId: number | null) {
const userId = UserFactory.getById(userId);
}
}

Further Reading #