On Beyond Frameworks: Architecting for a Changing Reality
In my last post, I discussed the general pros and cons we found when moving from .NET Framework to .NET Core. In future posts, we’ll discuss some of the specific technical challenges we faced, and how we worked around them. Before we get too technical in our look at migrating to .NET Core, however, let’s take a moment to review the architectural choices we made before we tried to switch frameworks. A large part of the reason that we were able to migrate from .NET Framework so quickly was because we separated our application logic from the decisions made by the framework.
Even if you’ve never coded in C#, and have no intention of changing that, stick around. The principles I discuss here are widely applicable, regardless of the specifics of the framework that you’re using.
The Promise of Frameworks
Frameworks are powerful tools that promise to make the process of building complicated software quick and easy (at least by comparison). They tackle difficult problems and can abstract away the complexities of common tasks.
To a large degree, frameworks have lived up to their promise. By hiding a great deal of the complexities of tasks like “run a server and respond to HTTP requests”, frameworks enable us to focus instead on the complexity that differentiates Pluralsight from Google. Frameworks also enable a number of interesting architectural choices. If every service was building up from TCP connections, microservices would never have gained the popularity that they currently enjoy.
The Cost of Frameworks
While they bring many benefits, frameworks also have an accompanying cost. There’s no such thing as a free lunch, after all, and for the same reason that there is no perfect architecture, there is no perfect and unchanging framework. I want to take a few minutes to talk about a couple of these costs: the cost of abstraction and the cost of change.
The Cost of Abstraction
My first introduction to the problems that abstractions can bring was Joel Spolsky’s post on the Law of Leaky Abstractions. He does a great job of explaining why abstractions, these wonderful things that we devise to make our lives easier, sometimes end up making them harder instead. Frameworks are chock full of abstractions–they often contain multiple layers of abstractions!
Most of the time, this is a good thing.
Instead of dealing with all the complexity of the HTTP specification, and all the ways that different people have abused and misinterpreted it, frameworks let us deal with relatively simple, logically consistent code.
But we need to remember that framework programmers are human too.
Sometimes this will show up with bugs or security vulnerabilities in the frameworks we depend on.
Other times you might discover that Content-Type
HTTP header isn’t added to the Headers
collection of an HttpResponse
, but is instead added to a Headers
collection on a Content
object on the HttpResponse
.
The fact that abstractions leak shouldn’t keep us from using them. We just need to remember that there are hidden costs to doing so.
The Cost of Change
The second cost of adding a framework that we often overlook is the cost of changing that framework. Like everything else in the software world, frameworks are in a constant state of change. Whether it’s because of an updated version of your current framework becoming available, or because a new framework comes along, promising to solve the problems you’re facing, it’s almost certain that you’ll end up changing the framework you built your code on. Suddenly, the framework that enabled you to get so much done so fast is getting in your way.
If you’re lucky, it’s as simple as needing to change the way a few things work. In many cases, though, you’re going to run into changes to the fundamental assumptions that your framework makes. When that happens, you’re likely to run into insidious little errors that will end up taking a lot of time and effort to track down.
An important thing to remember when you’re deep in the weeds fighting this kind of change is that this is a good problem to have! The fact that you’re changing the framework means that your code is productive. Code that just works, that doesn’t quite conform to your idealized architecture, that has been running half-forgotten for years is wonderfully successful code.
Living with our Frameworks
So, how do we get the best value from frameworks, while minimizing the costs they bring?
In my experience, one of the best tools for this kind of job is isolation.
Frameworks generally have a job to solve. Give them exactly enough space to solve the problem, and then build strong walls to keep them out of the rest of your code. This means, when using a framework like ASP.NET, that we let the framework solve all the problems with responding to HTTP requests, but put all the application logic somewhere else. In a lot of cases, I even like putting that logic in a separate assembly that doesn’t even reference ASP.NET at all, as an added layer of protection.
As an example, let’s take a look at an example controller from the ASP.NET Core web API tutorial. Following their instructions, you end up with a controller something like this:
[Route("api/controller")]
[ApiController]
public class TodoController : ControllerBase
{
private readonly TodoContext _context;
// Lots of other endpoints omitted for brevity
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteTodoItem(long id)
{
var todoItem = await _context.TodoItems.FindAsync(id);
if (todoItem == null)
{
return NotFound();
}
_context.TodoItems.Remove(todoItem);
await _context.SaveChangesAsync();
return NoContent();
}
}
Notice that this code contains references to two different frameworks at the same time: ASP.NET Core and Entity Framework. For the equivalent of a “Hello World” application, this is probably fine. If we ever have to change either of those frameworks, though, all of the logic around whether you can delete items and how to actually remove them is tangled up with the framework code.
A simple refactoring that reduces that coupling would be to take all the logic that doesn’t deal with responding to HTTP requests out of the controller. Then we end up with two classes:
[Route("api/controller")]
[ApiController]
public class TodoController : ControllerBase
{
private readonly TodoList _todoList;
// Lots of other endpoints omitted for brevity
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteTodoItem(long id)
{
var deleted = await _todoList.DeleteById(id)
if (!deleted)
{
return NotFound();
}
return NoContent();
}
}
public class TodoList
{
private readonly TodoContext _context;
public async Task<bool> DeleteById(long id)
{
var item = await _context.TodoItems.FindAsync(id);
if (item == null)
{
return false;
}
_context.TodoItems.Remove(item);
await _context.SaveChangesAsync();
return true;
}
}
If this were my project, I’d probably also want to extract any references to Entity Framework from TodoList
, so that it’s dealing only with plain old class objects.
I’d treat the entities generated for EF as DTOs, converting them into domain objects that I control completely before passing them out of a repository.
That gets a little complicated for a blog post, though.
There’s a cost to working this way. Frameworks try to solve more problems than I want them to, and it’s tempting to let the framework make architectural decisions for me. There’s also a level of duplication that becomes inevitable when I’m trying to keep the framework away from my domain objects. I’ll often have classes that look almost identical, with the only difference being that one is annotated for a framework, and the other is strictly code. In addition to that kind of obvious duplication, creating those different layers ends up making some secondary work. In the end, though, I find that it’s better to pay a small maintenance fee up front, rather than dealing with the catastrophic consequences at some unknown future time.
This is part two of an ongoing series about our transition to .NET Core. More information can be found in part 1 and part 3.