Why Build Software Frameworks
Quest for tackling complexity
I have been building software for a while. When it came to building B2B SaaS, the conventional patterns changed. I saw a general struggle in dealing with SaaS specific problems such as noisy neighbours or multi-tenancy. I saw custom frameworks being built to tackle SaaS complexities. While patterns and frameworks are as old as software engineering itself, but SaaS specific problems are relatively new and the solutions are still evolving. When it comes to building my own SaaS from scratch, I can’t help it but think about minimizing complexity while keeping options open. For example, I do not want to build microservices or tackle database write scaling with sharding at the beginning. But if it comes to it, I would like to achieve such engineering capabilities without forcing a major refactor.
The rest of this writeup is really a mindshare with the goal of discovering
- Is it possible to hide complexity and keep options open?
- At what cost?
Why build frameworks?
My understanding in building software systems for over a decade lead me to a simple answer: To hide complexity. That’s it!
Frameworks are inherently opinionated. They are restrictive. That is the fundamental difference between frameworks and libraries.
For example, libusb
provides a generic access to the USB devices attached to
your system. It is mostly not opinionated. It provides building blocks for
enumerating devices, perform device IO, register callbacks etc. However, if you
are building an application for web cams, you would probably end up writing
a thin layer on top of libusb
that “hides” the complexity of listing devices,
identifying if they are of the required class, supported vendor etc. so that
the business logic can simply focus on business logic and not worry about the
complexity of dealing with actual devices. Essentially you will end up building
a domain specific abstraction on top of libusb
.
Frameworks are essential when building business applications. You would want to restrict platform choices, reduce decisions and cognitive load for developers who are shipping business features every day. Ultimately, like it or not, software engineering productivity is measured by velocity ie. how quickly you can ship features that allow the business to experiment and move fast. However, great engineering teams add quality and security to the equation ie. move fast with quality and security. This is where frameworks play a key role of hiding complexity for business features, drive standardization, improve reliability and security and enable moving fast without compromising on quality.
Can we really hide complexity?
Let’s take the example of ActiveRecord.
User.joins(:groups)
.where(groups: { name: "developers" })
.where("users.updated_at < ?", 15.days.ago)
.update_all(is_inactive: true)
This is pretty self-explanatory and intuitive. But the complexity is hidden in the ActiveRecord framework. Behind this simple API lies the code that handles the complexity and heavy lifting of
- Database connection pooling
- Query generation and caching
- Query chaining using ActiveRelation
- Transaction management
- Result set handling, streaming and pagination
- Validation and error handling
- Migrations and schema management
That code is pretty complex and have evolved over time to support various features and edge cases. You may ask why not write SQL directly, after all the purpose of RDBMS is to provide the abstraction of SQL over a relational data model. Sure you can if raw performance is the goal. However, if your goal is to keep some option open for future enhancements such as read scaling using replicas, sharding etc. then you would probably wish that you have some abstractions where you can plug in your own customizations instead having to rewrite all database access code.
Keeping options open
Now let us consider a possibility where we have reached the point where we need to achieve write scaling through sharding of our RDBMS. We should probably need to decide the following
- What sharding stratey to use?
- Do we combined sharding and read replicas to achieve both write and read scaling?
- Can it be truly business logic agnostic?
If I have to consider these “future enhancements” at the time of initial development, I will probably end up introducing some concepts even if they are just NOP initially. Example:
class User < Sharding::ApplicationRecord
# This is a NOP for now
sharding_key :user_id
# This is a NOP for now
read_replica :enabled
end
Even though we are just declaring concepts, we are still introducing them and
potentially increasing the cognitive load for the developers. If we don’t,
future enhancements will either need to be entirely written as a ActiveRecord
and ActionController
hooks or will require refactoring all the models that
need to support sharding in future.
Embracing Complexity
Now that we are considering the possibility of a custom sharding layer over
Rails building blocks, we suddenly have to deal with the complexities of
ActiveRecord
and ActionController
internals. What was once hidden is no
longer so. The benefit however is, the complexity is still hidden from most of
the developers working on our code base. This makes sense if and when the
number of developers contributing to the code base is large enough to justify
the cost of embracing complexities hidden within a framework like Rails.
The paved path fallacy
Frameworks are opinionated. The initial engineers who built the framework essentially codified their opinion based on their experience at the time of initial design and development. Whatsoever be the purpose, let us assume the goal was to provide a paved path for other engineers in the team. While it may work for a while, over time the complexity of the framework will start to show. In addition, if the business actually succeeds and start to grow in scale, the opinions of the initial frameworks might fall short.
Lets critic our own example of hiding complexity with ActiveRecord
. One of
the common patterns is to use ActiveRecord
lifecycle hooks to perform various
tasks around the CRUD operations on the model. For example, it may seem
intuitive to associate business logic, notifications, metrics etc. with the
model. Now consider this
- Your model has grown with 100+ hooks
- What is the side effect of introducing a new hook?
- What happens when a hook mutates the state of the model?
- How to test each hook individually?
- How to test the interactions between hooks?
The point being, the opinions that backed an initial framework may not hold true for the future. At some point, the framework will evolve or it is time for a major refactor. Building a custom framework also means having the need to maintain the same and evolve it with the evolving requirements of the developers.
The cost of frameworks and abstractions
Frameworks are abstractions. By hiding complexity, they also hide the trade-offs that comes with the opinions of the framework. For example, ActiveRecord is an ORM that provides an object oriented view of the database. But relational data model is not object oriented. While it may seem easy and intuitive to start with, hiding the complexity of SQL comes at the cost of letting go of the power of SQL and the underlying database engine.
An alternative example to look at is TigerBeetle. A database built from scratch and tuned for performance by embracing the complexity of the underlying platform and hardware. This is especially at a time when we are building abstractions to hide the low level compute and refusing to deal with the complexities of low level systems programming.
Security
Security is probably one of the strong reason on building custom frameworks. Frameworks that bake in security controls and best practices leads to reduced cost of security fixes in the long run (IMHO). For example, I would consider the following minimal security controls to be a part of the framework right from the beginning:
- Input validation, all API / DTOs must have validation rules
- Authentication and authorization must be enforced by default
- All data operations must be segmented to resource owners (authorization at storage layer)
- All data operations must have strong limits enforced (prevents DoS)
- Auditing, logging and metrics (monitoring) for all critical operations
Conclusion
I will most probably follow a custom framework approach for building any serious software project in the near future. But I will be mindful about the cost. I hope the readers will consider the cost of building custom frameworks before deciding to take this path, especially during the early phase.
p.s: Nothing like switching to vim
for writing mindshares. Cursor or AI IDEs with
all its glories is still so distracting.