DRY Gone Bad: Bespoke Company Frameworks
There are some software development best practices out there that are universally and unambiguously true outside of any specific business context - “use source control” is a great example. These are rare.
The lion’s share of software industry “best practices” - SOLID principles, “Clean Code,” DRY, and so on - really have to be tempered or calibrated to the domain in order to avoid the pitfalls of “extreme best practice adoption.”
In the universe of best practices, DRY is most susceptible to extreme adoption.
DRY Gone Bad: Bespoke Company Frameworks (BCFs)
Quips about building in-house frameworks, instead of just building applications, are aplenty in the software community: “we don’t build applications here, just frameworks.”
I build frameworks for a living. They have their place: infrastructure problems. You use frameworks for building web applications, UIs, distributed systems, real-time systems, and so on. Frameworks eliminate the need to write MVC controllers or RPC functionality from the ground up each and every time.
Infrastructure is my business - it is not typically the business of the companies I work with in the medical, eCommerce, energy, manufacturing, finance, and gaming sectors.
Yet what do the lion’s share of these businesses have? Gruesomely complicated, bespoke, in-house frameworks.
These frameworks are created, almost always, prior to the first version of the application itself ever being developed - they’re created in anticipation of many domain entities inside the application’s scope needing similar behaviors.
Bespoke company frameworks are:
- 80% repurposeable domain logic and workflows - i.e. validation of models, persistence, etc;
- 20% infrastructure - i.e. ensuring that tracing or health monitoring happens in a consistent way throughout the domain, standardizing configuration, etc.
It’s the temptation to standardize the behavior of domain entities, message processing, persistence, et al across all parts of a domain or all domains period that is so dangerous - and a total misapplication of DRY. Doing this before the application is even fully designed which is almost always when this happens, makes it even more foolish.
An example:
So we have a standard methodology of composing the various components in our domain here, and they’re all used in a streamlined workflow that is enforced across all entities in the domain:
Validate --> If valid, Update --> If updated, Persist
This is a bit of a Rorschach test - upon seeing this you will either think:
- “Nice - they’ve separated their concerns using single responsibility classes, interfaces to promote testability and mockability, abstract base classes to help remove boilerplate, composition to help with encapsulation, and a streamlined process in order to make sure the development team does consistent work across all domains and all parts of each domain.”
- “This system is going to fall apart the first time any entity falls outside the standardized pathway with special requirements, extra steps, or literally anything that can’t easily conform to this design system and work-flow.”
The goal of this piece is to persuade you to be a number 2 person - someone who can spot the future problems before they become a source of rewrite-inducing technical debt that touches everything in the application.
BCFs are “DRY”
BCFs are created because the original developer(s) tasked with building the application want to coerce future developers down a small number of approved, blessed pathways. Creating friction via an intricate, standardized, and global design is actually a feature not a bug: make it cheaper for other developers to comply than to invent something new. This is one “DRY” form of reasoning.
The other is more straight forward: in order to enforce standardization there needs to be a single source of truth for how things are done. This means having standardized base classes, template methods, domain-type constraints, and generally an inheritance-driven approach to enforce behavior throughout the domain.
Developers create BCFs believing that they will:
- Help reduce the amount of boilerplate code developers need to write;
- Be an extensible design system that will scale with the domain; and
- Improve quality control outcomes - the BCF promotes testability, code re-use, standardization, and all sorts of other patterns that should theoretically do this.
This is, in practice, virtually never true.
The Trouble with BCFs
Across hundreds of code bases I’ve reviewed and many which I’ve either designed or worked on directly - BCFs never live up to their promises. In fact, they’re typically counter-productive and make it more difficult to achieve all three of the stated goals from earlier.
The reason is simple: real-world behaviors, even within a single domain, can’t be fitted neatly onto a single, standardized model. If they could then your company would be in the business of buying commoditized software from vendors, not hiring developers to author it.
The attempt to conform the domain to a single model of behavior always fails. What inevitably happens is an important use case emerges - and the requirements for implementing it are inexpressible under the BCF’s model.
Thus we accelerate towards rapid technical debt accumulation: option / settings classes are introduced to configure the behavior of the conforming base classes or a new layer is added to the inheritance hierarchy of base classes. Both of these are done in order to preserve the “standard way” of doing things, but now with the added complexity of new branches and additional classes to navigate.
We’re still clinging to DRY here - because that can’t be wrong. We just need a bigger framework capable of expressing more things!
Now repeat this over years - dozens of new use cases emerge that can’t be expressed well under the old model, so the old model has to be updated with yet more settings or base classes. The ball of mud forms slowly at first, and then all at once.
High Risk, Low Reward
Worst of all - after the BCF has made it safely into production, changing any of the existing BCF classes in any meaningful way to accommodate new requirements means touching every BCF’d domain and entity at the same time.
How confident are you that your test coverage is going to capture all of these changes across every part of your application? How confident are you that this change isn’t going to have negative, second-order effects?
If you’re changing something in the order to accommodate a change to the order fulfillment domain, what’s the business justification for that same change affecting accounting, reporting, and every other domain? It’d better be exceptionally good, otherwise this is just inmates running the asylum.
Tempering DRY
DRY’s goal is noble - we should try to avoid duplicating code in the name of making sure things are done consistently with code that is tested and well-understood. Code becomes safer the more frequently it’s used and across a wider number of use-cases - this exposes the edge cases, gets them fixed, and in a healthy project puts them under automated test coverage. DRY helps facilitate all of this.
“All of our weaknesses are our strengths taken to an extreme.”
When you go down the road of extreme adoption of best practices, treating the duplication of any similar-ish work-flow / business logic / any code whatsoever as a smell that “should” be eradicated from the code base, a Bespoke Company Framework is born.
This impulse has to be tempered by the reality it’ll eventually meet: even relatively simple domains will not fit into a singular model and you will destroy your own and your team’s productivity in the attempt.
Frameworks are best put to work attacking infrastructure problems, because those problems can largely be addressed via standardized models - TCP, HTTP, gRPC, NATS, and WebSockets behavior is domain-independent.
Business domains don’t work that way, so don’t try - it’s a fool’s errand.
Instead, if you want to reuse code, here’s what I suggest: DRY without being so literal about it.
Here’s an example below (full source) - apologies in advance for the formatting.
For what it’s worth, I’m using event-sourcing in this example, which is distinctly different from the CRUD sample I modeled in UML earlier. That has no bearing on our discussion around BCF - it’s just what I happened to have on-hand.
State
State is a simple C# record
type - it only implements a simple IWithProductId
interface that exposes the string ProductId
primarily for routing and serializer identification purposes.
/// <summary>
/// The state object responsible for all event and message processing
/// </summary>
public record ProductState : IWithProductId
{
public const int LowInventoryWarningThreshold = 3;
public ProductData Data { get; init; } = ProductData.Empty;
public string ProductId => Data.ProductId;
public PurchasingTotals Totals { get; init; } = PurchasingTotals.Empty;
public ImmutableSortedSet<ProductSold> Orders { get; init; } = ImmutableSortedSet<ProductSold>.Empty;
public ImmutableSortedSet<ProductInventoryWarningEvent> Warnings { get; init; } = ImmutableSortedSet<ProductInventoryWarningEvent>.Empty;
// TODO: could add product inventory change records here too
public ImmutableSortedSet<ProductInventoryChanged> InventoryChanges { get; init; } = ImmutableSortedSet<ProductInventoryChanged>.Empty;
public bool IsEmpty => Data == ProductData.Empty;
}
All data - no behaviors.
Processing
Processing behavior is implemented as static
extension methods in C# - I____Command
interfaces signal that these are input commands that will produce effects, I____Event
interface types are the effects produced. Events are matters of fact and therefore are not subject to validation - they either happened or they didn’t.
public static class ProductStateExtensions
{
/// <summary>
/// Stateful processing of commands. Performs input validation et al.
/// </summary>
/// <remarks>
/// Intentionally kept simple.
/// </remarks>
/// <param name="productState">The product state we're evaluating.</param>
/// <param name="productCommand">The command to process.</param>
/// <returns></returns>
public static ProductCommandResponse ProcessCommand(this ProductState productState, IProductCommand productCommand)
{
switch (productCommand)
{
case CreateProduct create when productState.IsEmpty:
{
if (productState.IsEmpty)
{
// not initialized, can create
}
else
{
// err
}
}
case SupplyProduct(var productId, var additionalQuantity) when !productState.IsEmpty:
// omitted for brevity
// if Price is 0, then it is likely not set
case PurchaseProduct purchase when !productState.IsEmpty && productState.Data.CurrentPrice > 0:
// omitted for brevity
default:
{
return new ProductCommandResponse(productCommand.ProductId, Array.Empty<IProductEvent>(), false,
$"Product with [Id={productState.Data.ProductId}] is not ready to process command [{productCommand}]");
}
}
}
public static ProductState ProcessEvent(this ProductState productState, IProductEvent productEvent)
{
switch (productEvent)
{
case ProductCreated(var productId, var productName, var price):
{
return productState with
{
Data = productState.Data with
{
ProductId = productId, CurrentPrice = price,
ProductName = productName
}
};
}
case ProductInventoryChanged(var productId, var quantity, var timestamp, var inventoryChangeReason) @event:
{
// omitted for brevity
}
case ProductInventoryWarningEvent warning:
{
// omitted for brevity
}
case ProductSold sold:
{
// omitted for brevity
}
default:
throw new ArgumentOutOfRangeException(nameof(productEvent));
}
}
}
The combination of static
methods and record
types is designed to help us ensure that all event-processing is done using pure functions, which helps us model operations idempotently. That’s a $10 way of saying I can call this method with the same input hundreds of times in a row and always get the same output - which is desirable.
Entity Implementation
We use a persistent Akka.NET actor to recover + write events; respond to commands; and respond to queries.
/// <summary>
/// Manages the state for a given product.
/// </summary>
public sealed class ProductTotalsActor : ReceivePersistentActor
{
public static Props GetProps(string persistenceId)
{
return Props.Create(() => new ProductTotalsActor(persistenceId));
}
/// <summary>
/// Used to help differentiate what type of entity this is inside Akka.Persistence's database
/// </summary>
public const string TotalsEntityNameConstant = "totals";
private readonly ILoggingAdapter _log = Context.GetLogger();
public ProductTotalsActor(string persistenceId)
{
PersistenceId = $"{TotalsEntityNameConstant}-" + persistenceId;
State = new ProductState();
Recover<SnapshotOffer>(offer =>
{
if (offer.Snapshot is ProductState state)
{
State = state;
}
});
Recover<IProductEvent>(productEvent =>
{
State = State.ProcessEvent(productEvent);
});
Command<IProductCommand>(cmd =>
{
var response = State.ProcessCommand(cmd);
var sentResponse = false;
var sender = Sender;
if (response.ResponseEvents.Any())
{
PersistAll(response.ResponseEvents, productEvent =>
{
_log.Info("Processed: {0}", productEvent);
if (productEvent is ProductInventoryWarningEvent warning)
{
_log.Warning(warning.ToString());
}
State = State.ProcessEvent(productEvent);
if (!sentResponse) // otherwise we'll generate a response-per-event
{
sentResponse = true;
sender.Tell(response);
}
if(LastSequenceNr % 10 == 0)
SaveSnapshot(State);
});
}
else
{
Sender.Tell(response);
}
});
Command<SaveSnapshotSuccess>(success =>
{
});
Command<FetchProduct>(fetch =>
{
Sender.Tell(new FetchResult(State));
if (State.IsEmpty)
{
// we don't exist, so don't let `remember-entities` keep us alive
Context.Parent.Tell(new Passivate(PoisonPill.Instance));
}
});
}
public override string PersistenceId { get; }
public ProductState State { get; set; }
}
Moving all of the state-processing instructions into some static methods (and I can further decompose those into smaller methods if the switch
statement gets too big to be easily readable) helps keep the actor small - it’s the same idea as having “skinny controllers” in MVC.
Keeping DRY
In my example above, I have some behaviors I want to enforce for this domain and any others I might choose in the future:
- All of the state is immutable, as are the command and event implementations. We want this in order to keep our functions pure and prevent side-effects for state that might be shared with multiple parties in the same process.
- Commands, events, and queries all have different semantic meanings when it comes to reasoning about effects on state - using distinct interfaces for each means we can quickly tell them apart inside our system.
- All of the events and commands implement simple interfaces which assist with routing and serialization.
- Aside from the choice of Akka.NET base class, there’s no inheritance in the solution.
What’s the difference between this and a BCF?
There’s no shared abstractions dictating the behavior of individual entities or domains - everything I’ve shown above belongs to our “products” domain. There’s no common “abstract StoreEntityActor
” that enforces similar behavior across any additional entities that might appear in my application.
Instead, what I’ve done is create a repeatable system that can be replicated for each entity or domain independently.
I’m trying to avoid developers on our team having to discover their own ways of how to reason about state, effects, and the flavors of message they might have to process. But I also don’t want to be overly prescriptive and force them to use a one-size-fits-all model either - that’s a BCF.
We intentionally don’t try to enforce uniform behavior across domains - instead we merely encourage and exemplify it. We’re going to trust our engineering team and development processes, rather than corral them with high friction code.
What if instead of persisting data to Akka.Persistence, one of our entities has to call an internal HTTP API to do it? That is solely that entity’s issue - there’s no shared abstraction that touches other entity types that has to be touched in order to facilitate this unique difference.
This is the key takeaway - DRY doesn’t literally mean every similarish thing should be shoehorned into the same base class or method in order to prevent code duplication. Rather, it’s that we want to avoid unnecessary duplication of code through carelessness and lack of organization.
Rather than build an ornate, top-down, and over-engineered framework to “raise quality” and unify the ununifiable - prefer simpler constructs and patterns that are easy to explain, easy to read, and easy to repeat. This will give you the shared standards and habits you’re looking for without coupling everything to the same set of abstractions.