Validate correctly on the edge of the app
We should validate data on the edges of the app in controllers using dry-validation
and dry-types
. Invalid states should not be allowed.
Bad - no validation π
- Allows bad data in database - data quality is poor so analysis cannot be done
- If dependencies assume the data is good, apparently unrelated code blows up days, weeks or months later, leading to difficult to diagnose defects
- If dependencies check the validity of the data, we get hundreds of lines of boilerplate showing up all over the app and we still canβt recover and have to crash anyway
- Sometimes we rely on JS to supply valid data to the backend - bad idea since JS is notoriously unreliable
- This kind of defect would be uncovered by using thorough unit tests, which implies testing is poor or non existent
Good - precise validation at the edge π
- Bad data should never be allowed into the app or the database
- Validation should happen at the edge - see Hexagonal Architecture literature for rationale
- For Rails, that means at the controller level
- Use
dry-validation
library for precise validation
Why dry-validation
? π
Another gem just to do validation? Itβs worth it:
- This gem is way more flexible and feature filled than the sloppy and tightly coupled validations in
ActiveModel
- By separating out the validation concern it means we are pushed towards a world where we cannot instantiate an invalid object - this avoids a huge Rails anti-pattern that is the root cause of this crash
- It plays really nicely with
Dry::Types
allowing us to define required enums concisely - Allows us to inject other objects to validate with. So in future we can plug in
US::City
and so validate the city exists in the database. Not done in this PR but itβd be a big step forwards! - Like
ActiveModel
it seamlessly supports translation of error messages for when we need to show errors to users - It separates out validation from the domain object giving a result object that responds to
#success?
and#failure?
. - We can call
#to_h
on the validation result to give a hash we can use to build theDry::Struct
seamlessly, cutting down on messy boilerplate code.
Example π
Bad - zero validation, sloppy testing, timebomb code π
- Accept any location then later app will blow up
Agent::Match::FlowsController#index
shows the anti-pattern:
module Agent
module Match
class FlowsController < BaseController
def index
@location = params[:location] # no validation, accepts any location
# ... snip ...
end
end
end
end
Good - precise validation, no bad data in database, shows user error on invalid data π
module Agent
module Match
module Location
# Describes a state using an enum - cannot be invalid
State = Dry.Types::String.enum(
'ALABAMA' => 'AL',
'CALIFORNIA' => 'CA',
# ... snip ...
).constructor(&:upcase)
# Describes a city - currently too loose - will look up city/state combo in db
City = Dry.Types::String.constructor(&:titleize)
# Validate defines the validation rules
class Validate < Dry::Validation::Contract
params do
required(:city).value(City).filled
required(:state).value(State).filled
end
end
# Raw converts from "Denver, CO" -> { city: "Denver", state: "CO" }
class Raw
def self.from_display_location(location)
raw_city, raw_state = location.split(",").map(&:strip)
{ city: raw_city, state: raw_state }
end
end
end
# Finally, protect against bad data at the controller level
class FlowsController < BaseController
def index
raw_location = Location::Raw.from_display_location(params[:location])
validation = Location::Validate.new.call(raw_location)
if validation.success?
@location = validation.to_h # { city: City.new('Denver'), state: State.new('CO') }
# ^^^ A hash with validated instances
# At this point, location is valid so can be depended on
# ... snip ...
else
redirect_to root_path, alert: "Invalid location #{location}" # Show error if location is invalid
end
end
end
end
end