- Notifications
You must be signed in to change notification settings - Fork50
Build maintainable Rails apps
License
andypike/rectify
Folders and files
Name | Name | Last commit message | Last commit date | |
---|---|---|---|---|
Repository files navigation
Rectify is a gem that provides some lightweight classes that will make it easierto build Rails applications in a more maintainable way. It's built on top ofseveral other gems and adds improved APIs to make things easier.
Rectify is an extraction from a number of projects that use these techniques andproved to be successful.
In June 2016, I spoke at RubyC about Rectify and how it can be used to improveareas of your application. The full video and slides can be found here:
Building maintainable Rails apps - RubyC 2016
To install, add it to yourGemfile
:
gem"rectify"
Then use Bundler to install it:
bundle install
Currently, Rectify consists of the following concepts:
You can use these separately or together to improve the structure of your Railsapplications.
The main problem that Rectify tries to solve is where your logic should go.Commonly, business logic is either placed in the controller or the model and theviews are filled with too much logic. The opinion of Rectify is that theseplaces are incorrect and that your models in particular are doing too much.
Rectify's opinion is that controllers should just be concerned with HTTP relatedthings and models should just be concerned with data relationships. The problemthen becomes, how and where do you place validations, queries and other businesslogic?
Using Rectify, Form Objects contain validations and represent the data inputof your system. Commands then take a Form Object (as well as other data) andperform a single action which is invoked by a controller. Query objectsencapsulate a single database query (and any logic it needs). Presenters containthe presentation logic in a way that is easily testable and keeps your views asclean as possible.
Rectify is designed to be very lightweight and allows you to use some or all ofit's components. We also advise to use these components where they make sensenot just blindly everywhere. More on that later.
Here's an example controller that shows details about a user and also allows auser to register an account. This creates a user, sends some emails, does somespecial auditing and integrates with a third party system:
classUserController <ApplicationControllerincludeRectify::ControllerHelpersdefshowpresentUserDetailsPresenter.new(:user=>current_user)enddefnew@form=RegistrationForm.newenddefcreate@form=RegistrationForm.from_params(params)RegisterAccount.call(@form)doon(:ok){redirect_todashboard_path}on(:invalid){render:new}on(:already_registered){redirect_tologin_path}endendend
TheRegistrationForm
Form Object encapsulates the relevant data that isrequired for the action and theRegisterAccount
Command encapsulates thebusiness logic of registering a new account. The controller is clean andbusiness logic now has a natural home:
HTTP => Controller (redirecting, rendering, etc)Data Input => Form Object (validation, acceptable input)Business Logic => Command (logic for a specific use case)Data Persistence => Model (relationships between models)Data Access => Query Object (database queries)View Logic => Presenter (formatting data)
The next sections will give further details about using Form Objects, Commandsand Presenters.
The role of the Form Object is to manage the input data for a given action. Itvalidates data and only allows whitelisted attributes (replacing the need forStrong Parameters). This is a departure from "The Rails Way" where the modelcontains the validations. Form Objects help to reduce the weight of your modelsfor one, but also, in an app of reasonable complexity even simple things likevalidations become harder because context is important.
For example, you can add validation for aUser
model but there are differentcontext where the validations change. When a user registers themselves you mighthave one set of validations, when an admin edits that user you might haveanother set, maybe even when a user edits themselves you may have a third. In"The Rails Way" you would have to have conditional validation in your model.With Rectify you can have a different Form Object per context and keep thingseasier to manage.
Form objects in Rectify are based onVirtusand make them compatible with Rails form builders, add ActiveModel validationsand all allow you to specify a model to mimic.
Here is how you define a form object:
classUserForm <Rectify::Formattribute:first_name,Stringattribute:last_name,Stringvalidates:first_name,:last_name,:presence=>trueend
You can then set that up in your controller instead of a normal ActiveRecordmodel:
classUsersController <ApplicationControllerdefnew@form=UserForm.newenddefcreate@form=UserForm.from_params(params)if@form.valid?# Do something interestingendendend
You can use the form object with form builders such assimple_form like this:
=simple_form_for@formdo |f|=f.input:first_name=f.input:last_name=f.submit
When the form is generated it uses the name of the form class to infer what"model" it should mimic. In the example above, it will mimic theUser
modelas it removes theForm
suffix from the form class name by default.
The model being mimicked affects two things about the form:
- The route path helpers to use as the url to post to, for example:
users_path
. - The parent key in the params hash that the controller receives, for example
user
in this case:
params={"id"=>"1","user"=>{"first_name"=>"Andy","last_name"=>"Pike"}}
You might want to mimic something different and use a form object that is notnamed in a way where the correct model can be mimicked. For example:
classUserForm <Rectify::Formmimic:teacherattribute:first_name,Stringattribute:last_name,Stringvalidates:first_name,:last_name,:presence=>trueend
In this example we are using the sameUserForm
class but am mimicking aTeacher
model. The above form will then use the route path helpersteachers_path
and the params key will beteacher
rather thanusers_path
anduser
respectively.
You define your attributes for your form object just like you do inVirtus.
By default, Rectify forms include anid
attribute for you so you don't need toadd that. We use thisid
attribute to fulfill some of the requirements ofActiveModel so your forms will work with form builders. For example, your formobject has a#persisted?
method. Your form object is never persisted sotechnically this should always returnfalse
.
However, you are normally representing something that is persistable. So we usethe value ofid
to workout if what this should return. Ifid
is a numbergreater than zero then we assume it is persisted otherwise we assume it isn't.This is important as it affects where your form is posted (to the#create
or#update
action in your controller).
There are a number of ways to populate attributes of a form object.
Constructor
You can use the constructor and pass it a hash of values:
form=UserForm.new(:first_name=>"Andy",:last_name=>"Pike")
Params hash
You can use the params hash that a Rails controller provides that contains allthe data in the request:
form=UserForm.from_params(params)
When populating from params we will populate the built inid
attribute fromthe root of the params hash and populate the rest of the form attributes fromwithin the parent key. For example:
params={"id"=>"1","user"=>{"first_name"=>"Andy","last_name"=>"Pike"}}form=UserForm.from_params(params)form.id# => 1form.first_name# => "Andy"form.last_name# => "Pike"
The other thing to notice is that (thanks to Virtus), attribute values are castto the correct type. The params hash is actually all string based but when youget values from the form, they are returned as the correct type (seeid
above).
In addition to the params hash, you may want to add additional contextual data.This can be done by supplying a second hash to the.from_params
method.Elements from this hash will be available to populate form attributes as if theywere under the params key:
form=UserForm.from_params(params,:ip_address=>"1.2.3.4")form.id# => 1form.first_name# => "Andy"form.last_name# => "Pike"form.ip_address# => "1.2.3.4"
Model
You can pass a Ruby object instance (which is normally an ActiveModelbut can be any PORO) to the form to populate it's attribute values. This is usefulwhen editing a model:
user=User.create(:first_name=>"Andy",:last_name=>"Pike")form=UserForm.from_model(user)form.id# => 1form.first_name# => "Andy"form.last_name# => "Pike"
This works by trying to match (deeply) the attributes of the form object with thepassed in object. If there is matching attribute or method in the model, thenwhatever it returns will be assigned to the form attribute.
This works great for most cases, but sometimes you need more control and need theability to do custom mapping from the model to the form. When this is required,you just need to implement the#map_model
method in your form object:
classUserForm <Rectify::Formattribute:full_name,Stringdefmap_model(model)self.full_name="#{model.first_name}#{model.last_name}"endend
The#map_model
method is called as part of.from_model
after all the automaticattribute assignment is complete.
One important thing that is different about Rectify forms is that they are notbound to a model. You can use a model to populate the form's attributes but thatis all it will do. It does not keep a reference to the model or interact withit.
Rectify forms are designed to be lightweight representations of the data youwant to collect or show in your forms, not something that is linked to a model.This allows you to create any form that you like which doesn't need to match therepresentation of the data in the database.
JSON
You can also populate a form object from a JSON string. Just pass it in to the.from_json
class method and the form will be created with the attributespopulated by matching names:
json=<<-JSON { "first_name": "Andy", "age": 38 }JSONform=UserForm.from_json(json)form.first_name# => "Andy"form.age# => 38
Populating the form from JSON can be useful when dealing with API requests intoyour system. Which allows you to easily access data and perform validation ifrequired.
Rectify includesActiveModel::Validations
for you so you can use all of theRails validations that you are used to within your models.
Your Form Object has a#valid?
method that will validate the attributes ofyour form as well as any (deeply) nested form objects and array attributes thatcontain form objects. There is also an#invalid?
method that returns theopposite of#valid?
.
The#valid?
and#invalid?
methods also take a set of options. These options allowyou to not validate nested form objects or array attributes that contain form objects.For example:
classUserForm <Rectify::Formattribute:name,Stringattribute:address,AddressFormattribute:contacts,Array[ContactForm]validates:name,:presence=>trueendclassAddressForm <Rectify::Formattribute:street,Stringattribute:town,Stringattribute:city,Stringattribute:post_code,Stringvalidates:street,:post_code,:presence=>trueendclassContactForm <Rectify::Formattribute:name,Stringattribute:number,Stringvalidates:name,:presence=>trueendform=UserForm.from_params(params)form.valid?(:exclude_nested=>true,:exclude_arrays=>true)
In this case, theUserForm
attributes will be validated (name
in the example above)but theaddress
andcontacts
will not be validated.
It's sometimes useful to have some context within your form objects when performingvalidations or some other type of data manipulation of the input. For example, youmight want to check that the current user owns a particular resource as part of yourvalidations. You could add the current user as an additional contextual option asthe example shows above. However, sometimes you need this context to be availableat all levels within your form not just at the root form object. You might have nestedforms or arrays of form objects and they all might need access to this context. Asthere is no link up the chain from child to parent forms, we need a way to supplysome context and make it available to all child forms.
You can do that using the#with_context
method.
form=UserForm.from_params(params).with_context(:user=>current_user)
This allows us to access#context
in any form, and use the information withinit when we perform validations or other work:
classPostForm <Rectify::Formattribute:blog_id,Integerattribute:title,Stringattribute:body,Stringattribute:tags,Array[TagForm]validate:check_blog_ownershipdefcheck_blog_ownershipreturnifcontext.user.blogs.exists?(:id=>blog_id)errors.add(:blog_id,"not owned by this user")endendclassTagForm <Rectify::Formattribute:name,Stringattribute:category_id,Integervalidate:check_categorydefcheck_categoryreturnifcontext.user.categories.exists?(:id=>category_id)errors.add(:category_id,"not a category for this user")endend
The context is passed to all nested forms within a form object to make it easyto perform all the validations and data conversions you might need from withinthe form object without having to do this as part of the command.
Did you notice in the example above that there was no mention of StrongParameters. That's because with Form Objects you do not need strong parameters.You only specify attributes in your form that are allowed to be accepted. Allother data in your params hash is ignored.
Take a look atVirtus for more informationabout how to build a form object.
Regarding internationalization, the main affected classes when coercing areDate
andTime
classes. This is coercing Strings intoDate
,DateTime
andTime
. Texts don't usually need to be coerced as they are simple String attributes with nothing special in them.
When coercing dates and times in a multi-language application, each locale will have its own date and time formats, and these formats should be taken into account when coercing strings (inputs entered by the user, or comming form external sources).
So forDate
,DateTime
andTime
classes, Rectify does not support I18n by default. But there are some ways to achieve it indirectly.
Probably the best is to define customVirtus::Attribute
s for each kind of temporal class. For exmaple:
classLocalizedDate <Virtus::Attributedefcoerce(value)returnvalueunlessvalue.is_a?(String)Date.strptime(value,I18n.t("date.formats.short"))rescueArgumentErrornilendend
Commands (also known as Service Objects) are the home of your business logic.They allow you to simplify your models and controllers and allow them to focuson what they are responsible for. A Command should encapsulate a single usertask such as registering for a new account or placing an order. You of coursedon't need to put all code for this task within the Command, you can (andshould) create other classes that your Command uses to perform it's work.
With regard to naming, Rectify suggests using verbs rather than nouns forCommand class names, for exampleRegisterAccount
,PlaceOrder
orGenerateEndOfYearReport
. Notice that we don't suffix commands withCommand
orService
or similar.
Commands in Rectify are based onWisperwhich allows classes to broadcast events for publish/subscribe capabilities.Rectify::Command
is a lightweight class that gives an alternate API and addssome helper methods to improve Command logic.
The reason for using the pub/sub model rather than returning a result means thatwe can reduce the number of conditionals in our code as the outcome of a Commandmight be more complex than just success or failure.
Here is an example Command with the structure Rectify suggests (as seen in theoverview above):
classRegisterAccount <Rectify::Commanddefinitialize(form)@form=formenddefcallreturnbroadcast(:invalid)ifform.invalid?transactiondocreate_usernotify_adminsaudit_eventsend_user_details_to_crmendbroadcast(:ok)endprivateattr_reader:formdefcreate_user# ...enddefnotify_admins# ...enddefaudit_event# ...enddefsend_user_details_to_crm# ...endend
To invoke this Command, you would do the following:
defcreate@form=RegistrationForm.from_params(params)RegisterAccount.call(@form)doon(:ok){redirect_todashboard_path}on(:invalid){render:new}on(:already_registered){redirect_tologin_path}endend
When you call the.call
class method, Rectify will instantiate a new instanceof the command and will pass the parameters to it's constructor, it will thencall the instance method#call
on the newly created command object. The.call
method also allows you to supply a block where you can handle the eventsthat may have been broadcast from the command.
The events that your Command broadcasts can be anything, Rectify suggests:ok
for success and:invalid
if the form data is not valid, but it's totally up toyou.
From here you can choose to implement your Command how you see fit. ARectify::Command
only has to have the instance method#call
.
As your application grows and Commands get more complex we recommend using thestructure above. Within the#call
method you first check that the input datais valid. If it is you then perform the various tasks that need to be completed.We recommend using private methods for each step that are well named which makesit very easy for anyone reading the code to workout what it does.
Feel free to use other classes and objects where appropriate to keep your codewell organized and maintainable.
Just as inWisper, you fire events usingthebroadcast
method. You can use any event name you like. You can also passparameters to the handling block:
# within the command:classRegisterAccount <Rectify::Commanddefcall# ...broadcast(:ok,user)endend# within the controller:defcreateRegisterAccount.call(@form)doon(:ok){ |user|logger.info("#{user.first_name} created")}endend
When an event is handled, the appropriate block is called in the context of thecontroller. Basically, any method call within the block is delegated back to thecontroller.
As well as capturing events in a block, the command will also return a hash of thebroadcast events together with any parameters that were passed. For example:
events=RegisterAccount.call(form)events# => { :ok => user }
There will be a key for each event broadcast and its value will be the parameterspassed. If there is a single parameter it will be the value. If there are noparameters or many, the hash value for the event key will be an array of the parameters:
events=RegisterAccount.call(form)events# => {# :ok => user,# :messages => ["User registered", "Email sent", "Account ready"],# :next => []# }
You may occasionally want to expose a value within a handler block to the view.You do this via theexpose
method within the handler block. If you want touseexpose
then you must include theRectify::ControllerHelpers
module inyour controller. You pass a hash of the variables you wish to expose to the viewand they will then be available. If you have set a Presenter for the view thenexpose
will try to set an attribute on that presenter. If there is noPresenter or the Presenter doesn't have a matching attribute thenexpose
willset an instance variable of the same name. See below for more details aboutPresenters.
# within the controller:includeRectify::ControllerHelpersdefcreatepresentHomePresenter.new(:name=>"Guest")RegisterAccount.call(@form)doon(:ok){ |user|expose(:name=>user.name,:greeting=>"Hello")}endend
<!-- within the view: --><p><%=@greeting%><%=presenter.name%></p># =><p>Hello Andy</p>
Take a look atWisper for moreinformation around how to do publish/subscribe.
A Presenter is a class that contains the presentational logic for your views.These are also known as an "exhibit", "view model", "view object" or just a"view" (Rails views are actually templates, but anyway). To avoid confusionRectify calls these classes Presenters.
It's often the case that you need some logic that is just for the UI. The samequestion comes up, where should this logic go? You could put it directly in theview, add it to the model or create a helper. Rectify's opinion is that all ofthese are incorrect. Instead, create a Presenter for the view (or component ofthe view) and place your logic here. These classes are easily testable andprovide a more object oriented approach to the problem.
To create a Presenter just derive off ofRectify::Presenter
, add attributes asyou do for Form Objects usingVirtusattribute
declaration. Inside a Presenter you have access to all view helpermethods so it's easy to move the presentation logic here:
classUserDetailsPresenter <Rectify::Presenterattribute:user,Userdefedit_linkreturn""unlessuser.admin?link_to"Edit#{user.name}",edit_user_path(user)endend
Once you have a Presenter, you typically create it in your controller and makeit accessible to your views. There are two ways to do that. The first way is tojust treat it as a normal class:
classUsersController <ApplicationControllerdefshowuser=User.find(params[:id])@presenter=UserDetailsPresenter.new(:user=>user).attach_controller(self)endend
You need to call#attach_controller
and pass it a controller instance which willallow it access to the view helpers. You can then use the Presenter in yourviews as you would expect:
<p><%=@presenter.edit_link%></p>
The second way is a little cleaner as we have supplied a few helper methods toclean up remove some of the boilerplate. You need to include theRectify::ControllerHelpers
module and then use thepresent
helper:
classUsersController <ApplicationControllerincludeRectify::ControllerHelpersdefshowuser=User.find(params[:id])presentUserDetailsPresenter.new(:user=>user)endend
In your view, you can access this presenter using thepresenter
helper method:
<p><%=presenter.edit_link%></p>
We recommend having a single Presenter per view but you may want to have morethan one presenter. You can use a Presenter to to hold the presentation logicof your layout or for a component view. To do this, you can either use the firstmethod above or use thepresent
method and add afor
option with any key:
classApplicationController <ActionController::BaseincludeRectify::ControllerHelpersbefore_action{presentLayoutPresenter.new(:user=>user),:for=>:layout}end
To access this Presenter in the view, just pass the Presenter key to thepresenter
method like so:
<p><%=presenter(:layout).login_link%></p>
After a presenter has been instantiated you can update it's values by justsetting their attributes:
classUsersController <ApplicationControllerincludeRectify::ControllerHelpersdefshowuser=User.find(params[:id])presentUserDetailsPresenter.new(:user=>user)presenter.user=User.firstend# or...defother_actionuser=User.find(params[:id])@presenter=UserDetailsPresenter.new(:user=>user).attach_controller(self)@presenter.user=User.firstendend
As mentioned above in the Commands section, you can use theexpose
method (ifyou includeRectify::ControllerHelpers
). You can use this anywhere in thecontroller action including the Command handler block. If you have set aPresenter for the view thenexpose
will try to set an attribute on thatpresenter. If there is no Presenter or the Presenter doesn't have a matchingattribute thenexpose
will set an instance variable of the same name:
classUsersController <ApplicationControllerincludeRectify::ControllerHelpersdefshowuser=User.find(params[:id])presentUserDetailsPresenter.new(:user=>user)expose(:user=>User.first,:message=>"Hello there!")# presenter.user == User.first# @message == "Hello there!"endend
Another option for containing your UI logic is to use a Decorator. Rectifydoesn't ship with a built in way to create a decorator but we recommend eitherusingDraper or you can roll your ownusingSimpleDelegator
:
classUserDecorator <SimpleDelegatordeffull_name"#{first_name}#{last_name}"endenduser=User.new(:first_name=>"Andy",:last_name=>"Pike")decorator=UserDecorator.new(user)decorator.full_name# => "Andy Pike"
If you want to decorate a collection of objects you can do that by adding thefor_collection
method:
classUserDecorator <SimpleDelegator# ...defself.for_collection(users)users.map{ |u|new(u)}endendusers=UserDecorator.for_collection(User.all)user.eachdo |u|u.full_name# => Works for each user :o)end
The final main component to Rectify is the Query Object. It's role is toencapsulate a single database query and any logic that it query needs tooperate. It still uses ActiveRecord but adds some very light sugar on the top tomake this style of architecture easier. This helps to keep your model classeslean and gives a natural home to this code.
To create a query object, you create a new class and derive off ofRectify::Query
. The only thing you need to do is to implement the#query
method and return anActiveRecord::Relation
object from it:
classActiveUsers <Rectify::QuerydefqueryUser.where(:active=>true)endend
To use this object, you just instantiate it and then use one of the followingmethods to make use of it:
ActiveUsers.new.count# => Returns the number of recordsActiveUsers.new.first# => Returns the first recordActiveUsers.new.exists?# => Returns true if there are any records, else falseActiveUsers.new.none?# => Returns true if there are no records, else falseActiveUsers.new.to_a# => Execute the query and returns the resulting objectsActiveUsers.new.eachdo |user|# => Iterates over each resultputsuser.nameendActiveUsers.new.map(&:age)# => All Enumerable methods
Passing data that your queries need to operate is best done via the constructor:
classUsersOlderThan <Rectify::Querydefinitialize(age)@age=ageenddefqueryUser.where("age > ?",@age)endendUsersOlderThan.new(25).count# => Returns the number of users over 25 years old
Sometimes your queries will need to do a little work with the provided databefore they can use it. Having your query encapsulated in an object makes thiseasy and maintainable (here's a trivial example):
classUsersWithBlacklistedEmail <Rectify::Querydefinitialize(blacklist)@blacklist=blacklistenddefqueryUser.where(:email=>blacklisted_emails)endprivatedefblacklisted_emails@blacklist.map{ |b|b.email.strip.downcase}endend
One of this great features of ActiveRecord is the ability to easily composequeries together in a simple way which helps reusability. Rectify Query Objectscan also be combined to created composed queries using the|
operator as weuse in Ruby for Set Union. Here's how it looks:
active_users_over_20=ActiveUsers.new |UsersOlderThan.new(20)active_users_over_20.count# => Returns number of active users over 20 years old
You can union many queries in this manner which will result in anotherRectify::Query
object that you can use just like any other. This results in asingle database query.
As an alternative you can also use the#merge
method which is simply an aliasof the|
operator:
active_users_over_20=ActiveUsers.new.merge(UsersOlderThan.new(20))active_users_over_20.count# => Returns number of active users over 20 years old
The.merge
class method ofRectify::Query
accepts multipleRectify::Query
objects to union together. This is the same as using the|
operator on multipleRectify::Query
objects.
active_users_over_20=Rectify::Query.merge(ActiveUsers.new,UsersOlderThan.new(20))active_users_over_20.count# => Returns number of active users over 20 years old
You can also pass aRectify::Query
object into the constructor of anotherRectify::Query
object to set it as the base scope.
classUsersOlderThan <Rectify::Querydefinitialize(age,scope=AllUsers.new)@age=age@scope=scopeenddefquery@scope.query.where("age > ?",@age)endendUsersOlderThan.new(20,ActiveUsers.new).count
UsingActiveRecord::Relation
is a great way to construct your database queriesbut sometimes you need to to use features of your database that aren't supportedby ActiveRecord directly. These are usually database specific and can greatlyimprove your query efficiency. When that happens, you will need to write someraw SQL. Rectify Query Objects allow for this. In addition to your#query
method returning anActiveRecord::Relation
you can also return an array ofobjects. This means you can run raw SQL usingActiveRecord::Querying#find_by_sql
:
classUsersOverUsingSql <Rectify::Querydefinitialize(age)@age=ageenddefqueryUser.find_by_sql(["SELECT * FROM users WHERE age > :age ORDER BY age ASC",{:age=>@age}])endend
When you do this, the normalRectify::Query
methods are available but theyoperate on the returned array rather than on theActiveRecord::Relation
. Thisincludes composition using the|
operator but you can't compose anActiveRecord::Relation
query object with one that returns an array of objectsfrom its#query
method. You can compose two queries where both return arraysbut be aware that this will query the database for each query object and thenperform a Ruby array set union on the results. This might not be the mostefficient way to get the results so only use this when you are sure it's theright thing to do.
The above example is fine for short SQL statements but if you are using raw SQL,they will probably be much longer than a single line. Rectify provides a smallmodule that you can include to makes your query objects cleaner:
classUsersOverUsingSql <Rectify::QueryincludeRectify::SqlQuerydefinitialize(age)@age=ageenddefmodelUserenddefsql<<-SQL.strip_heredoc SELECT * FROM users WHERE age > :age ORDER BY age ASC SQLenddefparams{:age=>@age}endend
Just includeRectify::SqlQuery
in your query object and then supply the amodel
method that returns the model of the returned objects. Aparams
method that returns a hash containing named parameters that the SQLstatement requires. Lastly, you must supply asql
method that returns the rawSQL. We recommend using a heredoc which makes the SQL much cleaner and easierto read. Parameters use the ActiveRecord standard symbol notation as shown abovewith the:age
parameter.
Now that you have your queries nicely encapsulated, it's now easier with a cleardivision of responsibility to improve how you use the database within yourtests. You should unit test your Query Objects to ensure they return the correctdata from a know database state.
What you can now do it stub out these database calls when you use them in otherclasses. This improves your test code in a couple of ways:
- You need less database setup code within your tests. Normally you might usesomething like factory_girl to create records in your database and then whenyour tests run they query this set of data. Stubbing the queries within yourtests can reduce this complexity.
- Fewer database queries running and less factory usage means that your tests
- are doing less work and therefore will run a bit faster.
In Rectify, we provide the RSpec helper methodstub_query
that will makestubbing Query Objects easy:
# inside spec/rails_helper.rbrequire"rectify/rspec"RSpec.configuredo |config|# snip ...config.includeRectify::RSpec::Helpersend# within a spec:it"returns the number of users"dostub_query(UsersOlderThan,:results=>[User.new,User.new])expect(subject.awesome_method).toeq(2)end
As a convenience:results
accepts either an array of objects or a singleinstance:
stub_query(UsersOlderThan,:results=>[User.new,User.new])stub_query(UsersOlderThan,:results=>User.new)
The next inevitable question is "Where do I put my Forms, Commands, Queries andPresenters?". You could createforms
,commands
,queries
andpresenters
folders and follow the Rails Way. Rectify suggests grouping your classes byfeature rather than by pattern. For example, create a folder calledcore
(thiscan be anything) and within that, create a folder for each broad feature of yourapplication. Something like the following:
.└── app ├── controllers ├── core │ ├── billing │ ├── fulfillment │ ├── ordering │ ├── reporting │ └── security ├── models └── views
Then you would place your classes in the appropriate feature folder. If youfollow this pattern remember to namespace your classes with a matching modulewhich will allow Rails to load them:
# in app/core/billing/send_invoice.rbmoduleBillingclassSendInvoice <Rectify::Command# ...endend
You don't need to alter your load path as everything in theapp
folder isloaded automatically.
As stated above, if you prefer not to use this method of organizing your codethen that is totally fine. Just create folders underapp
for the things inRectify that you use:
.└── app ├── commands ├── controllers ├── forms ├── models ├── presenters ├── queries └── views
You don't need to make any configuration changes for your preferred folderstructure, just use whichever you feel most comfortable with.
This style of Rails architecture is not a silver bullet for all projects. Ifyour app is pretty much just basic CRUD then you are unlikely to get muchbenefit from this. However, if your app is more than just CRUD then you shouldsee an improvement in code structure and maintainability.
The downside to this approach is that there will be many more classes and filesto deal with. This can be tricky as the application gets bigger to hold thewhole system in your head. Personally I would prefer that as maintaining it willbe easier as all code around a specific user task is on one place.
Before you use these methods in your project, consider the trade off and usethese strategies where they make sense for you and your project. It maybe mostpragmatic to use a mixture of the classic Rails Way and the Rectify approachdepending on the complexity of different areas of your application.
Some tests (specifically for Query objects) we need access to a database thatActiveRecord can connect to. We use SQLite for this at present. When you run thespecs withbundle exec rspec
, the database will be created for you.
There are some Rake tasks to help with the management of this test databaseusing normal(ish) commands from Rails:
rake db:migrate# => Migrates the test databaserake db:schema# => Dumps database schemarake g:migration# => Create a new migration file (use snake_case name)
Bump the version inlib/rectify/version.rb
then do the following:
bundlegem build rectify.gemspecgem push rectify-0.0.0.gem
About
Build maintainable Rails apps