Start with why?
Have you ever made an implementation decision in an application that turned out to be incorrect? Have you ever got a ‘production ready’ system and realised you have no way to test it? If so, this post is for you.
If I was to ask you what is it that makes your software systems stand out, what would you say? Would it be the fact your entire system runs on AWS Lambda? Or how about that you’ve got a custom built Kubernetes platform that developers can deploy to? NoSQL vs SQL?
Of course, it’s none of these things. What makes your system stand out is its core features. What is the unique selling point that convinces your customers to choose your product over one of its competitors? It’s how quickly you can respond to the changing needs of your customers.
Anything outside of these two points is an implementation detail:
- What is the core domain of your system that makes it stand out from the crowd?
- How maintainable is it and how quickly can you respond to change?
Making a technology choice because everyone else is doing it is not a convincing reason to do anything. I learned that lesson the hard way. In a previous job, I spent days implementing Kafka to allow 5 loosely coupled services to run independently. It was an interesting technology to work with but, in hindsight, the whole thing would have run better as a monolith. Couple that with the fact I was the only one who understood Kafka, and the maintainability took a nosedive.
In my attempts to reduce the coupling between the parts of my applications, I’d tightly coupled the parts to a more valuable resource. A single developer’s time.
I saw Kafka; I was interested in it, my developer magpie took over and I made an unsuitable implementation decision. I did that because it was interesting. I started from the technology choice, not from the problem space my application was solving.
Serverless or containers? Kubernetes or Amazon ECS? Kafka or Amazon Kinesis?
None of these decisions matter, at least in the short term.
I am not advocating for big upfront design. What I am an advocate of is to start every new software project with your core business logic, forgetting all implementation details. Then let the code drive the design of your system components.
Design with code
What does it mean to do upfront thinking?
Domain Driven Design (DDD) is one of the seminal books on software system design. Written by Eric Evans in August 2003 it should be at the top of the reading list for any architect or engineer.
Amongst the many other concepts in DDD, one of the most useful is the concept of bounded contexts. A bounded context is a logical boundary around a part of your application. Note, this does not need to equal a separate micro-service. It could be a modular monolith.
Each model within a bounded context exists only within that context. Take the simple example of an eCommerce application. The term product has several meanings based on who you speak to. In order processing, we care about the price and the quantity of an order. In inventory management, we care about stock levels.
Whilst these are the same product, conceptually they are different things in our application.
If we were to dive straight in and start building this application, we may end up with a product class like this.
public class Product
{
public string ItemSKU { get; set; }
public decimal Price { get; set; }
public decimal QuantityOrdered { get; set; }
public decimal QuantityInStock { get; set; }
public decimal QuantityOnPurchaseOrder { get; set; }
public void UpdateItemPrice(decimal newPrice)
{
//....
}
public void AddToPurchaseOrder(decimal quantity)
{
//....
}
public void Ordered(decimal quantityOnOrder)
{
//....
}
}
Our product class now understands how to manage orders, purchasing and stock. Inevitably, this will lead us down a path of having one big Product class that controls our entire application.
Understanding this up front is a vital part of software maintainability.
We could approach this problem by spending days talking about the system, whiteboarding and drawing up design documents. Whilst this would work, a lot of problems don’t appear until code is written.
There’s a two-step process to get around this, that doesn’t require a lot of designing. A way that gets you writing code quickly whilst not committing down any specific path:
- Start with your core business logic, leave all implementation details until the last possible moment
- Practice test driven development
Start with your core business logic
Code becomes infinitely more difficult to refactor once you introduce a dependency. Whether that be a database technology, messaging system or a compute provider. At first, only write ‘pure’ business logic code.
Remember, it’s your business logic that is your key differentiator. Getting this right and making it maintainable is going to have a tangible effect on the success of your system.
Going back to our product class for a moment. Writing that code, you may realise that the product class is doing too much. If you had already tied that to a database implementation change becomes difficult. If it is pure business code with any implementations hidden behind well-defined abstractions, change becomes easier.
Seeing this may make you realise that you have a boundary. A product is conceptually different things based on the context it is in. So let’s split that into a separate module.
This strikes the perfect balance. You are getting hands on and writing actual code. No amount of white boarding provides a better way of understanding your problem space. However, it leaves flexibility to change direction.
Test Driven Development
Hand in hand with this approach is test driven development. As you change and develop your core domain, you want to ensure you aren’t breaking any functionality. Practicing test driven development gives you that safety net to change your code whilst ensuring you aren’t breaking any functionality.
Writing code that is testable has the added benefit of producing code that is maintainable.
Let’s imagine you dive straight in and use AWS Lambda because all the cool kids are doing it. We may end up implementing a Lambda function that looks something like this:
public class Function
{
public async Task<APIGatewayProxyResponse> FunctionHandler(APIGatewayProxyRequest apigProxyEvent)
{
if (string.IsNullOrEmpty(apigProxyEvent.Body))
{
// Handle HTTP response...
}
var command = JsonSerializer.Deserialize<CreateProductCommand>(apigProxyEvent.Body);
if (string.IsNullOrEmpty(command.ItemSKU) || command.Price <= 0)
{
throw new ArgumentException("Invalid command");
}
var product = new Product(command.ItemSKU, command.Price);
using var connection = new SqlConnection("CONNECTION_STRING");
using var command = new SqlCommand("", connection);
await connection.OpenAsync();
await command.ExecuteNonQueryAsync();
// Additional business logic to update the product quantity in stock...
// Additional business logic to create an initial purchase order...
// Handle HTTP response...
}
}
This code may be functional, but how would you test it? We tied the implementation details up with our business logic, making it almost impossible to test without standing up additional infrastructure.
The second problem with this is that we are now tied to an implementation detail. Here, AWS Lambda. What if we discover during development that the business logic to create a purchase order takes 20 minutes? Given AWS Lambda has a 15 minute timeout, it is unsuitable. But our code is so coupled with the intricacies of Lambda, how would we move away?
If we had approached this differently and started with the business logic, it may have looked like this.
public class CreateProductCommandHandler
{
private readonly IProductRepository _productRepository;
private readonly IInventoryService _inventoryService;
private readonly IPurchasing _purchasingService;
public CreateProductCommandHandler(IProductRepository productRepository, IInventoryService inventoryService, IPurchasing purchasingService)
{
_productRepository = productRepository;
_inventoryService = inventoryService;
_purchasingService = purchasingService;
}
public async Task<Product> Handler(CreateProductCommand command)
{
if (string.IsNullOrEmpty(command.ItemSKU) || command.Price <= 0)
{
throw new ArgumentException("Invalid command");
}
var product = new Product(command.ItemSKU, command.Price);
await this._productRepository.Create(product);
await this._inventoryService.UpdateInventory(product);
await this._purchasingService.CreatePurchaseOrder(product);
return product;
}
}
We now have a core piece of business logic that has no dependencies on anything outside of our code base. This code is easily testable, we can inject mock implementations of our interfaces and use that to understand how different inputs flow through our logic. It also allows us to easily express the intent of our business logic. For anyone reading this who isn’t a .NET developer, I’m sure you can still grasp what the intention of the code.
Test Driven Development won’t solve all our problems. After implementing this, we may still end up committing to AWS Lambda before realising in production that our purchasing service takes 20 minutes. It doesn’t matter though, Lambda is an implementation detail. All our Lambda function is doing is handling the intricacies of Lambda before handing the actual work off to our business logic.
This provides an un-paralleled level of flexibility. Here is my create product process running 3 different hosting options.
Lambda
public class Function
{
private static CreateProductCommandHandler _handler;
public Function() : this(null)
{
}
internal Function(CreateProductCommandHandler handler)
{
_handler = handler ?? new CreateProductCommandHandler(new SqlProductRepository(), new IInventoryService(), new PurchasingService());
}
public async Task<APIGatewayProxyResponse> FunctionHandler(APIGatewayProxyRequest apigProxyEvent)
{
if (string.IsNullOrEmpty(apigProxyEvent.Body))
{
// Handle HTTP response...
}
var command = JsonSerializer.Deserialize<CreateProductCommand>(apigProxyEvent.Body);
var createdProduct = await this._handker.Handle(command);
// Handle HTTP response...
}
}
ASP.NET Web API
[ApiController]
[Route("[controller]")]
public class ProductController : ControllerBase
{
private readonly CreateProductCommandHandler _handler;
public ProductController(CreateProductCommandHandler handler)
{
_handler = handler;
}
[HttpPost(Name = "CreateProduct")]
public async Task<Product> CreateProduct([FromBody] CreateProductCommand command)
{
var createdProduct = await _handler.Handler(command);
return createdProduct;
}
}
Console Application
var handler = new CreateProductCommandHandler(new SqlProductRepository(), new IInventoryService(), new PurchasingService());
Console.WriteLine("What is the item SKU?");
var itemSku = Console.ReadLine();
Console.WriteLine("What is the price?");
var price = decimal.Parse(Console.ReadLine());
var command = new CreateProductCommand(itemSku, price);
await handler.Handle(command);
Console.WriteLine("Product created");
You’ll notice the only difference is the specific requirements of the technology. Lambda requires us to parse the HTTP request. ASP.NET takes away a lot of boilerplate and the console app requires us to read data from console inputs.
We can run the same code in 3 different compute options with 0 changes to our business logic. Code that isn’t changed has a much higher chance of stability. Our code is maintainable, flexible and resilient to changes in technology. And one thing that is certain in technology is that things will change.
Writing software in this way makes your core business logic, your unique selling point, more resistant to that change. Who wouldn’t want more of that?