It’s nearly 2023, Ruby has been released about 26 years ago with Rails 9 years later. To say that Rails has dominated the Ruby web ecosystem is to say nothing. Writing web apps with almost no effort as quickly as it can get with the simplicity of a hammer? Hell yeah, sign me up! Rails’ design appeared brilliant and felt like the Holy Grail of web development. Conventions made us so comfortable that we didn’t have to think at all. Everything we needed to do was to allow Rails to take our hands and guide us through life.
But it’s been 18 years on the market, and the simplicity of the framework has been proven to be insufficient for more complex applications. The ultimate culprit of Rails has to be its commitment to MVC design pattern. Yes, this is a very good reason why Rails managed to stay simple over the years and retained its ability to lure new developers. For beginners and for small projects it’s still a great choice.
For bigger platforms, MVC is far from enough though. It’s a land of plenty for issues when your application grows rapidly. All of a sudden nearly every advantage that Rails offer when coding simpler applications turns against us. Every mechanism that is easy to use becomes overgrown, overused, and extremely difficult to maintain. It’s easy to lose control and start practicing Fear-Driven Development®. Rails do not scale, and that’s a fact.
This is where dry-rb enters the room! Wearing all white. dry-rb is a set of libraries that are only coupled to some root components and can be used in separation from the rest of the pack. It’s a toolset rather than a framework. It doesn’t come with a web server, it doesn’t have any project architecture, and it is not designed to handle web requests on its own. And so it's just an addition rather than a ready-to-go solution, but a very important one. The one that could fix most of the Rails’ shortcomings.
The first step
Alright! While dry-rb comes with many interesting concepts and components let’s take it step by step and start with the simplest and the most commonly used of the libraries. We’re talking dry-validation.
So let’s consider this example:
class User < ApplicationRecord
include ModuleWithAdditionalValidations
validates :first_name, presence: true
validates :last_name, presence: true
validates :email, presence: true, uniqueness: true, format: { with: Const::EMAIL_RE }
validates :terms_of_service, acceptance: true
end
What’s wrong with it?
Well. It depends. If that’s a university assignment or a hobby app it’s cool, it can stay like that as it just meets requirements and expectations.
But what if we’re designing a super complex application with tens of models with complicated links between them? And with conditional validations. It can slowly get out of hand. It will be harder and harder to maintain this sort of validations and this sort of design. Please note that the example shows validations only. Following Rails’ conventions, there would be associations, callbacks, scopes, attribute accessors, methods, and all the other stuff. See how it gets more difficult to maintain?
Ok, but with some effort, it is still possible to keep the code in reasonably good shape. Growing complexity is not exactly an ActiveRecord validation killer. So let’s consider the following: what if we’re working with microservices with multiple databases? And we need to propagate some change across many applications with events? Do we copy the same validations across the whole system? Do we extract models into some sort of shared library? Both of the aforementioned solutions stay in direct contradiction with the basic idea of microservices. We don’t do that here.
So how do we approach this?
What if we decouple data validation and persistence? What if we validate data first and only once validated pass the data to the persistence layer? This way we can also run validations very early in the process of handling incoming requests. After all, validation errors should be returned as soon as possible to prevent other code from unnecessary execution. To achieve that we must think of data validation the same way we think of authentication and authorization. It is just another step in the request-handling flow. We do it at some point when processing the request and just pass the validated data further on. And we do it once, right when we receive the input, and not every time model is touched. Sounds great?
Ok, let’s go!
The makeover
As mentioned in the previous section, we’d like to make the validation request-specific and not model-specific. We’ll be performing validations per request, not per model save. Thus we’ll use naming referring to our endpoints rather than models. And so this is how SignUpContract
could look like:
class SignUpContract < Dry::Validation::Contract
params do
optional(:id).filled(Types::ResourceID)
optional(:first_name).filled(Types::Coercible::String)
optional(:last_name).filled(Types::Coercible::String)
required(:email).filled(Types::Email)
required(:password).filled(Types::Password)
required(:terms_of_service).filled(Types::Bool, eql?: true)
end
end
Yeah, right, but there are some sketchy things going on here. Where is that Types
module? And what does it do? You guessed it - we’ve got another library for that! dry-validation uses dry-types under the hood to define expected input. The library introduces a neat typing system to Ruby. You can find out more about it here. Nevertheless, dry-types do not include the types of ResourceID
, Email
, or Password
. We’ll define them ourselves by extending the standard dry-types set.
module Types
include Dry::Types()
DowncasedStrippedString = String.constructor(->(s) { s.strip.downcase })
ResourceID = String.constrained(format: Const::UUID_RE)
Email = DowncasedStrippedString.constrained(format: Const::EMAIL_RE)
Password = String.constrained(format: Const::PASSWORD_RE)
end
It’s essential to grasp the concept of a type here. Those class-looking like objects in the module are in fact validators composed with dry-types DSL. Instead of repeating the same validation rules over and over again for different contracts we can brew a type and its constraints in one module and reuse it everywhere. This way we create a full-fledged data type. From now on we can claim that the particular value should be an email, or a password and not just a string with proper format. Nice abstraction layer if you asked me.
Ok, let’s get back to our model:
class User < ApplicationRecord
end
Pretty, isn’t it? And here’s how your controller action could look like:
class AuthenticationController < ApplicationController
def sign_up(contract: SignUpContract.new)
# ...
user =
validate_params_with(contract) do |user_data|
User.create(user_data)
end
# ...
end
private
def validate_params_with(contract)
validation_result = contract.call(params.to_unsafe_h)
if validation_result.failure?
raise SomeValidationError, validation_result.errors.to_h
end
block_given? ? yield(validation_result.to_h) : validation_result.to_h
end
end
The code above is merely a should-be-working example of how contracts could be used with Rails. The idea behind it is to simply show the basic usage rather than provide a ready solution. It took 0 coffees to write it so we’d highly encourage all the by-passers to create their own implementations. Sorry, copy-paste enthusiasts.
Now that we know all the beauty of request-specific validations let’s be grumpy a bit and talk about some disadvantages. Nothing’s really perfect and it’s good to know both pros and cons of the solutions we consider implementing. The most important drawback of this approach will have to be a more difficult data integrity assurance in general. It’s easier to achieve it with models as models validate the data on every persistence call (unless we specifically tell them not to). In this case, we’d need to be either very careful with our validations and always ensure that every endpoint is secured with a properly implemented validation contract or we’d need to resort to persistence-layer constraints. The same issue affects microservices architecture. Except there it’s more severe and persistence-layer constraints don’t make much sense. Another characteristic that might be debatable is the need to define schemas for each request accepting data. It brings explicitness, which is good, but it creates more work as usually there are more data-accepting endpoints than there are models.
The wrap
- Rails’ conventions do not scale and aren’t suitable for complex projects.
- Rails’ models are overloaded with responsibilities which lead to maintenance issues.
- Model validations tend to wreak more and more havoc over time when confronted with the complexity of life. Conditional model validations bring tears to all developers’ eyes.
- Model validations do not meet modern system architectures’ standards.
- The real improvement to application design is switching the approach from model-specific validation to request-specific validation. This constitutes a software design change.
- Request-specific validation enhances explicitness. The validations are executed exactly when we call them as opposed to model validation being fired out of control when dealing with nested resources.
- Request-specific validation works well with all the architectures, including ones using event messaging.
- With great power comes great responsibility - data integrity needs more care and attention with request-specific validations.
- dry-rb is a swiss army knife in the world of Ruby libraries. It helps us solve Rails’ issues neatly and efficiently.
- dry-validation contracts help facilitate data validation outside of Rails’ models and enable us to compose complex validation rules with simple DSL.
- dry-validation contracts bring precision to our code. Their DSL allows us to brew schemas in the greatest detail and tailor the data to our needs.
- dry-types type concept helps us define and reuse new kinds of basic types that may appear in our domain contexts.
- We’ve barely scratched the surface of dry-rb based validation. Please pay a visit to dry-rb main page and see this bad boy’s capabilities for yourself.
- We may post more about more advanced cases and usages in the future.
- And we definitely plan to post more about dry-rb and the rest of their tools.
- I use word validation a lot. Validation, validation, validate, validating, validation.
That’s all folks.
Credits
Cover image by pch.vector on Freepik