Engineering for Evolution: Techniques to Future-Proof Your Code
Nicklas Millard
published December 10, 2023
There are plenty of different mechanisms and techniques to enable extensibility in your applications.
In this article, I'll give you deeper insight into how to think about extensibility, what concerns you need to address, and techniques to analyze and anticipate where change is likely to happen.
Throw YAGNI out the window and start building for change.
YAGNI is fine for school code, but it has little place in the real world.
This article is based on my own, real world experiences from delivering 20 software development projects. The examples and technical problem space presented here are directly inspired from actual implementations, although simplified.
By the way, after my introductory text, there’ll be plenty of code examples.
Let’s back up a bit. What is extensibility?
Before we head deeper into the implementation practicalities, let's take a second to build shared understanding and language for talking about extensibility and its affordances.
The 10.000 feet description is: extensibility refers to the ability to modify or enhance the functionality of an existing product, service, or library.
Ways to achieve this are to either inject new functionality into an otherwise set structure, or extend the array of options to pick from when performing a task.
In practice, extensibility takes many shapes, and different approaches offer varying levels of flexibility. It’s a balancing game that you improve at over time.
When talking about creating extensible code, you most often need to consider:
- What type of extensibility do I want to provide?
- What are the constraints that others have to adhere to?
- Where do I provide extension points?
- How much effort does it take to extend the functionality?
Roughly speaking, an extension point is anywhere that you let others add code that you don't control.
In my daily work as a software development consultant, I need to consider how my team is going to use the code that I write. I need to allow the team to extend upon my code so that it can do more than initially thought of.
Allowing for extensibility is to anticipate things will change. It allows us to swap one implementation for another, while still keeping the code readable and easily understandable.
Let's explore several extensibility techniques. Generally, I categorize them into the following groups:
- Replace it.
- Create your own.
- Add on.
Replace it—swapping out parts
Say we need to build schedules for our business users which perform a certain action. The initial business problem is simple enough. The schedule needs a defined interval and action to execute.
Our initial implementation may look something like this:
public class Schedule
{
public required DateOnly ActivationDate { get; init; }
public required TimeOnly TimeToRun { get; init; }
public required ScheduleInterval Interval { get; init; }
public bool IsTimeToRun()
{
// Some implementation that calculates time to run
// Ignore the details here.
switch (Interval)
{
case ScheduleInterval.Weekly:
return true;
case ScheduleInterval.Monthly:
return true;
default:
throw new ArgumentOutOfRangeException();
}
}
public void Run()
{
// Some implementation
}
}
public enum ScheduleInterval {
Weekly,
Monthly,
}
// Creating a schedule
var schedule = new Schedule
{
ActivationDate = new DateOnly(2023, 11, 26),
TimeToRun = new TimeOnly(12, 00, 00),
Interval = ScheduleInterval.Weekly
};
So, our current implementation is simple. It's fairly easy to understand. But, we have two main issues:
- How do we replace the action that is performed when Run() is called?
- How does it extend beyond only weekly and monthly schedules?
Let's tackle the first issue, since it bears the most business value.
We arrive at something like this:
public class Schedule
{
// Left out previous properties
/// <summary>
/// Maps to the type name of an IJobDefinition
/// </summary>
public required string JobDefinitionName { get; init; }
// Removed
// public void Run() {}
}
public interface IJobDefinition
{
public void Run();
}
public class SimpleJob : IJobDefinition
{
public void Run()
{
// Do something
}
}
Taking a step back and analyzing what we're trying to achieve, and the level of flexibility we want, we see that it makes sense to perform a class extract refactoring, taking out the Run() method and turn it into an interface of its own.
Now, a schedule's responsibility is reduced to "when do I need to run and what do I run" rather than also "how do I run it."
The "how" is placed in an entirely new class.
The second issue is regarding when it's time to run. Using an enum appears like an uncomplicated approach at first. However, extending upon enums rapidly evolves into a complex challenge since branching on discrete values is destined for headaches.
This is especially true if you're providing the scheduling service to other teams in your company. They may start to request all sorts of new schedule intervals you haven't thought of, and using an enum means having to modify existing code, recompile, and redploy.
We want others to effortlessly extend upon our schedule service.
So, let's now deal with the interval aspect, allowing our users to define their own trigger times.
public interface ITriggerTime
{
public bool IsTimeToRun(Schedule schedule);
}
public class WeeklyTrigger : ITriggerTime
{
private readonly DayOfWeek dayOfWeek;
public WeeklyTrigger(DayOfWeek dayOfWeek)
{
this.dayOfWeek = dayOfWeek;
}
public bool IsTimeToRun(Schedule schedule)
{
// calculate
return true;
}
}
public class MonthlyTrigger : ITriggerTime
{
public bool IsTimeToRun(Schedule schedule)
{
// calculate
return true;
}
}
public class Schedule
{
// Left out previous properties
public required ITriggerTime Interval { get; init; }
public bool IsTimeToRun()
{
return Interval.IsTimeToRun(this);
}
}
To sum up, we've set a predefined structure for what a schedule is, but at the same time provided two extension points others can use to modify behavior and extend functionality.
This technique, replacing parts of a whole, is also called composition.
The next problem—though outside this article's scope—is how to store a schedule in a database and retrieve it in full.
Create your own — extend with entirely new types
Say you need to do a lot of API calls in your application. Each call maps the response to a plain C# object—a quite common operation for many applications.
var fromDate = DateTimeOffset.Now;
var toDate = DateTimeOffset.Now;
var status = "pending";
var client = new HttpClient()
{
BaseAddress = new Uri("http://localhost:8080")
};
var response = await client.GetFromJsonAsync<IEnumerable<TransactionResponse>>(
$"api/transactions?fromDate={fromDate}&toDate={toDate}&status={status}"
);
This is simple enough. But having this many places within your application may lead to challenges down the road, and the headache of tracking down where the calls are made once the API changes are cumbersome to say the least.
One approach to deal with this is to introduce a common way to perform API calls, such as a typed HTTP client. With this approach, you'll create a single HTTP service class which wraps the calls.
public class TransactionsHttpClient
{
private readonly HttpClient client;
public TransactionsHttpClient(HttpClient client)
{
this.client = client;
}
public async Task<IEnumerable<TransactionResponse>> GetTransactions(
DateTimeOffset from,
DateTimeOffset to,
string status)
{
var response = await client.GetFromJsonAsync<IEnumerable<TransactionResponse>>(
$"api/transactions?fromDate={from}&toDate={to}&status={status}"
);
return response ?? new List<TransactionResponse>();
}
// Add more methods as endpoints become available
}
When new endpoints are introduced, you'll need to modify that HTTP service class. While the typed HTTP client is a great pattern, it quickly feels like pushing an upcoming challenge to future maintainers (including future-you).
This will at some point within the next 5 years become unmanageable. Also, think of the scenarios where you have multiple typed clients, and a service class that depend upon them. The service class' constructor will get out of control.
Let's think of a different approach that allows for incredible extensibility, ease of use, and stays maintainable in the long run: we allow others to create their own request object that maps into a URL.
We'd like to do the following:
[Endpoint("api/transactions")]
public class TransactionsRequest : Input<IEnumerable<TransactionResponse>>
{
[QueryParameter<IsoDateOnlyFormatter>("from")]
public DateOnly From { get; set; }
[QueryParameter<IsoDateOnlyFormatter>("to")]
public DateOnly To { get; set; }
[QueryParameter("status")]
public string? Status { get; set; }
}
// Usage
var client = new HttpClient
{
BaseAddress = new Uri("http://localhost:8080")
};
var transactionsRequest = new TransactionsRequest
{
From = new DateOnly(2023, 12, 1),
To = new DateOnly(2023, 12, 10),
Status = "pending"
};
// -> localhost:8080/api/transactions?from=2023-12-01&to=2023-12-10&status=pending
IEnumerable<TransactionResponse> response = await client.SendRequestAsync(transactionsRequest);
There's slightly too much code for it to make sense showing here, but, to see how this particular part works, check out the code on github here.
I turned this into a library of its own, that anyone can use. Check it out here.
Now, your team can use whatever request object that are already available, and create whatever they may need in the future.
This approach is opposed to the old adage "composition over inheritance". Here, inheritance is what allows us to focus on what we want rather than how to accomplish it.
Add on—allowing dynamic preconditions
Leveraging preconditions, a defensive programming technique, is a powerful way to ensure correctness.
Prior to running a piece of functionality, you'll verify if certain preconditions are satisfied. If any of the preconditions are not met, the execution is stopped. You might even consider returning a list of the conditions that weren't fulfilled.
This technique is also referred to as guard clauses.
A common approach to deal with this scenario is simply using if statements.
Now, imagine this approach when you want to evaluate if a user adheres to all your company's compliance policies. To start off, this is easy, there are only a few rules, and we can easily verify these with simple if statements.
public class UserComplianceEvaluator
{
private readonly IUserRepository userRepository;
private readonly IUserConfigurationRepository configurationRepository;
public UserComplianceEvaluator(IUserRepository userRepository,
IUserConfigurationRepository configurationRepository)
{
this.userRepository = userRepository;
this.configurationRepository = configurationRepository;
}
public IEnumerable<string> Evaluate(User user)
{
var errors = new List<string>();
if (userRepository.Exists(user)) errors.Add("User with that username already exists");
List<char> allowedCharacters = configurationRepository.GetAllowedCharacters();
bool isValidUsername = user.Username.ToLower().All(c => allowedCharacters.Contains(c));
if (!isValidUsername) errors.Add($"Invalid username, characters allowed are {string.Join(",", allowedCharacters)}");
return errors;
}
}
Regular if statements are fine for this purpose in many cases.
However, when what you're doing isn't straightforward input checking, but the checks are actual business rules within a policy, then you should really start considering another approach.
There's an enormous likelihood that at some point during the next 5 years, the business will update its current user compliance policy. One of the goals of creating lasting software is to make as few changes as possible to existing code.
With this in mind, can you spot why the current UserComplianceEvaluator is poorly designed?
- You'll need to modify the
Evaluate(user)method every time a new rule is introduced. - Whenever a new dependency is introduced into the constructor, you'll have to hunt down breaking code in various places.
- Unit tests will break left and right whenever any new change is introduced into the
UserComplianceEvaluator. - Future maintainers of the UserComplianceEvaluator class will have to read and modify an ever-growing
Evaluate(user)method.
The solution is to extract and externalize each business rule, thereby simplifying the class considerably, reducing it to only knowing that a series of compliance rules need to be evaluated, but not how each rule is evaluated.
// Passed between each rule
public class RuleContext
{
private readonly List<(Type, string)> errors = [];
public void AddError(Type type, string error) => errors.Add((type, error));
public IEnumerable<(Type Type, string Error)> Errors => errors.AsReadOnly();
}
public interface IUserRule
{
bool IsValid(User user, RuleContext context);
}
public class UsernameAvailableRule : IUserRule
{
private readonly IUserRepository userRepository;
public UsernameAvailableRule(IUserRepository userRepository)
{
this.userRepository = userRepository;
}
public bool IsValid(User user, RuleContext context)
{
bool exists = userRepository.Exists(user);
if (exists) context.AddError(GetType(), "User with that username already exists");
return !exists;
}
}
public class AllowedCharactersOnlyRule : IUserRule
{
private readonly IUserConfigurationRepository configurationRepository;
public AllowedCharactersOnlyRule(IUserConfigurationRepository configurationRepository)
{
this.configurationRepository = configurationRepository;
}
public bool IsValid(User user, RuleContext context)
{
List<char> allowedCharacters = configurationRepository.GetAllowedCharacters();
bool isValid = user.Username.ToLower().All(c => allowedCharacters.Contains(c));
if (!isValid) context.AddError(GetType(), $"Invalid username, characters allowed are {string.Join(",", allowedCharacters)}");
return isValid;
}
}
public class UserComplianceEvaluator
{
private readonly List<IUserRule> rules;
public UserComplianceEvaluator(IEnumerable<IUserRule> initialRules)
{
rules = initialRules.ToList();
}
public IEnumerable<string> Execute(User user)
{
var context = new RuleContext();
rules.ForEach(rule => rule.IsValid(user, context));
return context.Errors.Select(e => e.Error);
}
public UserComplianceEvaluator AddRule(IUserRule rule)
{
rules.Add(rule);
return this;
}
}
You see, it's definitely more classes and higher overall system complexity, though the cognitive and cyclomatic complexity of each piece is reduced.
But this also allows us — and our fellow application developers — to extend upon the application with minimal effort.
One of the derived affordances of this approach is testing individual rules has become immensely simpler. Before, you'd have to reason about the entire method and set it up to hit each case, now, you can test the rules in isolation
This type of extensibility is different from the compositional (replace), since we're not really replacing anything, we're adding new functionality on top of the existing
As a sidenote, we could potentially also remove rules when they become irrelevant, but, that's still different from replacing functionality.
A good rule of thumb is: if it's a business rule or policy, then it will change.
Let's Stay in touch
You're always welcome to reach out at nicklas@mjukvare.com!