I hope you enjoyed creating some games with Gosu last week. For now, it's back to business. This installment of Ruby Unbundled kicks off a new thread that looks at two interconnected topics: system design and the launch of new features in Rails applications.
Don’t worry, this series is not about ivory-tower architecture. In fact, it is the polar opposite. Our goal is to help you rapidly develop amazing new capabilities with Ruby-on-Rails and get them in the hands of your customers. In addition to designing these features, we will discuss tools, techniques, and gems you can use to safely and successfully launch your new features.
Before I forget, you don’t want to miss any of this thread, so jump on Twitter now and follow me at @DarrenBroemmer.
There is only one reason we design software
Let’s start with the meta question: Why do we bother with software design at all ?
I will claim here that there is only one reason we design software. It’s not so that we can draw nice looking architecture diagrams. It’s not so we are happy with the aesthetics of our code at the end of a sprint, although most of us enjoy that aspect of the craft. No, there is only one reason that we design software: so that it is easy to make changes tomorrow.
Because there will be changes to your application tomorrow. That is a given. Nothing is ever “finished”. This is true both in business and personal projects. How many times have you looked at an open source or pet project you worked on many years ago, and immediately began thinking about what you wanted to add to it?
If you accept our motivational hypothesis, then every design question should be centered around this issue. When we consider how to design new applications and capabilities, think about how they will evolve in the future.
Rails is known as a startup technology for good reason. You can build apps extremely quickly. The framework also gets criticized for leading to monolithic architectures. But the reason monoliths are problematic is their difficulty to adapt. There are no absolutes here, almost all design is relative. You know how whenever you ask a senior engineer on the team what they think about a particular decision, the answer usually begins with “That depends...”
Best practices may not be the best for your situation
When you search for Rails anti-patterns and best practices, you will find statements such as, don’t put too much logic in the controllers/models/views/etc. These are less so best practices as they are notional concepts to be applied in the context of your application and its features. There are no absolutes. Two hundred lines of code in one model class may be too much for a given feature, while it's not enough for another. We want to push logic down to the lowest level possible, but in homage to Einstein, no farther. More on this concept later.
It is also critical to consider that while it would be nice to make any change easy, opportunity cost comes into play. For our domain, we must look at what may reasonably need to change, understanding we won’t get our predictions right 100% of the time.
As an example, I’ve seen too many projects over the decades that added layers of indirection so that “we can swap out the database implementation if we need to.” How many of those projects actually ever changed the database? Right, almost none. Those that did likely moved from an RDBMS to a NoSQL database, which required more extensive code changes anyway due to nuances in the programming models.
Indirection and other design constructs are not cost-free. Thus, giving them a bit of thought is well worth the time investment. For example, a general guideline is to keep individual methods fairly short, about the size of a visual screen worth of text as an upper bound. However, I’ve worked on code-bases that strictly adhered to that principle, but there were so many layers of indirection that by the ninth object in the stack, I almost gave up trying to find where the “actual work” took place. Not helpful, and although I’m no longer on that project, I’m fairly certain they didn’t need to make changes to all nine of those indirection concerns.
Before closing this section, let me be clear that there are indeed tactical “best practices”, or more accurately, coding practices, that do apply almost universally. For example, to avoid SQL injection attacks, don’t take user input and include it directly in a SQL string interpolation. No arguments here on that one. I am focused on strategic design decisions as opposed to what I would call tactical techniques and patterns.
Push logic down to the lowest level possible
From a practical perspective then, how do you make design decisions? Where does the “business logic” belong in Rails?
Let’s begin with the first question, as it is easier to address. After a long career in software development, I have reached the conclusion that the phrase “business logic” is a misnomer. More on that later.
Given a concern, there are two key factors to consider on deciding where and how to implement that logic.
- What is the lowest layer in the architecture where this logic makes sense
- How likely is the concern to change or have variations
The first question addresses reusability, encapsulation, and the DRY principle (Don’t Repeat Yourself). Given that dependencies generally flow down through the layers, logic can be reused across more use cases the lower down it is located in the stack. For example, field level validations or restrictions typically belong in a Model class, as any insert or update to the database will go through the model.
We can put validations in the controller, but what if we add another use case tomorrow that modifies that data? We may add a new REST service that goes through a different controller method. We need to remember to add the same logic in the second controller, or refactor the logic out to a helper. We may also choose to add validation logic in the View, however that would be in addition to model checks so that we can fail fast.
The second question addresses whether we should use something like a Factory, Strategy, or Command pattern to implement the concern. The alternative is simply to put logic inline within a given component. My assertion here is that “inline logic” within a controller, for example, is not inherently a bad thing. Controllers can be viewed as roughly equivalent to services, thus they often face these types of design choices. If logic is specific to that service, don’t add needless indirection. Keep it simple where you can.
Some purists will argue that a controller should only deal with HTTP-related concerns and delegate all logic to lower layers. This is a fine design choice, if you find it useful for your domain. However, on its own merits, you may just end up with controllers that package parameters into data transfer objects and a layer of indirection without gaining much benefit.
Putting these concepts into practice
Consider a banking application as an example. I know its a tired example, but bear with me as it is readily understood by almost all readers. We will briefly discuss three use cases:
- The addition of fees charged on transfers
For withdrawals, we need to validate that a customer is not trying to take out more money than is stored in their account. This logic fits well in the Account model class, as it is relevant anywhere a withdrawal happens. We have pushed the logic down the layers as far as we can.
However, the logic for a transfer between two accounts is better suited for a controller or a standalone service object. If we think the controller is the only place that transfers will occur, then that choice is fine. However, if it happens in other use cases, or it likely may in the future, a delegate class is better.
Where should I put this logic? Oh yes, in the helpers.
But here we hit a decision point in Rails, as there is no prescribed directory for “service classes”. While Rails is highly opinionated on web application specific topics (i.e. Controllers in this case), it doesn’t prescribe where these “business logic” classes belong. Oh wait, I used a term there I’m not supposed to, but you know what I mean.
The closest choice is the helpers directory, and a frequent problem is that helpers become a dumping ground for all kinds of logic. Here is where a little bit of thought and planning goes a long way. Your team should make structural choices related to these types of concerns and stick to them. Code reviews are key, and pipeline guardrails are even better. More on those later as well.
Remember at the beginning, we asserted that the goal of design is to make change easier. On account transfers in our banking application, let’s say the business wants to charge customers a 0.5% fee on account transfers. I wouldn’t bank there, but we’ll use this for our thought exercise.
Where we really get into trouble, and a key characteristic of monolith applications, is that a change to a given concern requires changes to logic spread across our code-base. If we need to change code scattered across controllers, helpers, views, and models, our job becomes a lot tougher. It will take longer to reason about the change, design, and implement it. You also will be going into a number of component-level test suites to make corresponding additions or changes. Assuming tests make up a significant amount of your code and thus, code review time, it's a non-trivial change.
Each element seems straightforward by itself, but we need to do the following to complete this “feature”.
- Inform the user on the user interface (they will need to know)
- Calculate the fee amount
- Modify the validation logic to verify the balance is at least the transfer amount plus fee amount (unless you are going to bill them later)
- Add bookkeeping for the fees, to ensure they are both deducted and credited to the bank
Rarely changes can be completely encapsulated in only one place, as most non-trivial features require some change to the user interface as well as the backend. Our goal then should be to limit the backend changes to as few components as possible.
Using our guidelines, we ask how far down can we push this logic? We need to broker the change with the user interface so we can inform the user of the existence and amount of the fee. We also need to broker that the fee deduction goes to the bank instead of the customer’s destination account for the transfer. It seems that the controller, or service layer, is a good choice.
Microservices are more than just a buzzword
Many design decisions will look similar to this one, and it's not by accident. This is why service-oriented architecture and microservices are pervasive trends. In fact, a microservice (which could be a REST endpoint or logically speaking simply a delegate Ruby class ) that handles fees should likely be a part of the design as well. Then the service change is to a) calculate the fee amount and b) invoke the fee service with that amount.
Using existing components or services should always be a design goal. It's hard to introduce a bug in a line of code that you don’t write. Not impossible, just more difficult.
We will expand more on that theme as we continue by taking a look at different gems that help us expedite the launch of our features. Many changes seem simple on the surface, but take longer than we think. A professor I had in college expressed the formula as effort equals the developer’s estimate multiplied by eight. Not exact, but fairly accurate.
Thus, code or component reuse should always be considered closely, especially when there are some terrific gems available to use. In the next article in the series, we will dive into code and some relevant gems as we prepare to launch these features. We need to start collecting that fee revenue :-)