Skip to content

Application example

The business we will be discussing is simple. We plan to establish a book library where registered users can create new books and rent them.

Book library requirements

We need a basic book tracking system in which every book is identified by a title. The following rules will apply:

  • The book list will be publicly accessible to everyone.
  • Only users who are logged in will be allowed to modify the book list.
  • Logged-in users will have the ability to edit or delete a book.

The rental system

Logged-in users will have the ability to manage books, taking into account the following:

  • Users should be able to rent any available book.
  • Only logged-in users will be able to rent books.
  • A book will be marked as unavailable while it is rented out to someone.
  • After a book is returned, it will be marked as available once again.

The books wishlist

Logged-in users will have the ability to manage their wishlist, taking into account the following:

  • Users should be able to add books to their wishlist if the book is currently unavailable and they have not yet read it.
  • If a user has already read a book, adding it to their wishlist would not be practical.
  • Once a book on a user's wishlist becomes available, the system should notify them.
  • When the book is rented by someone that has the book on the wishlist, it should be removed after return.

The application domain is straightforward, and we will be building this small logic case step by step to demonstrate how Granite can simplify and streamline certain aspects of your application.

New project setup

We are working here with Rails 5.1, however, Granite currently supports Rails versions up to 7.x.x. You can find an example of this application following the link: https://github.com/toptal/example_granite_application

Generating a new project

This tutorial is using Rails version 5.1.4, and the first step is to install it:

gem install rails -v=5.1.4

Now, with the proper Rails version, let's start a new project:

rails new library
cd library

Let's start setting up the database for development:

rails db:setup

Setup Devise

Let's add devise to control users access and have a simple control over logged users. Adding it to Gemfile.

gem 'devise'

Run bundle install and then generate the default devise resources.

rails generate devise:install

And then, let's create a simple devise model to interact with:

rails generate devise user

And migrate again:

rails db:migrate

Info

If you get in any trouble in this section, please check the updated documentation on the official website.

Setup Granite

Add granite to your Gemfile:

gem 'granite'

And bundle install again.

Add require 'granite/rspec' to your rails_helper.rb. Check more details on the testing section.

Warning

If you get in any trouble in this section, please report an issue.

Books domain

Book::Create action

It's time to create our first model and has some domain on it.

Let's use a scaffold to have a starting point with the Book model:

rails g scaffold book title:string

Now, we can start working on the first business action.

Let's generate the boilerplate business action class with Rails granite generator:

rails g granite book/create

The following class was generated:

# apq/actions/ba/book/create.rb
class BA::Book::Create < BA::Book::BusinessAction
  allow_if { false }

  precondition do
  end

  private

  def execute_perform!(*)
    subject.save!
  end
end

Additionally, a "base", shared class is generated, that defines the subject type for all the inherited classes in the namespace Book.

class BA::Book::BusinessAction < BaseAction
  subject :book
end

Policies

The generated code says allow_if { false } and we need to restrict it to logged in users. Let's replace this line to restrict the action only for logged in users:

# apq/actions/ba/book/create.rb
class BA::Book::Create < BA::Book::BusinessAction
  allow_if { performer.is_a?(User) }
  # ...
end

And let's start testing it:

require 'rails_helper'
RSpec.describe BA::Book::Create do
  subject(:action) { described_class.as(performer).new }
  let(:performer) { User.new }

  describe 'policies' do
    it { is_expected.to be_allowed }

    context 'when the user is not authorized' do
      let(:performer) { double }
      it { is_expected.not_to be_allowed }
    end
  end
end

Attributes

We also need to be specific about what attributes this action can touch and then we need to define attributes for it:

# apq/actions/ba/book/create.rb
class BA::Book::Create < BA::Book::BusinessAction
  # ...
  represents :title, of: :subject
  # ...
end

We can define some validations to not allow saving without specifying a title:

# apq/actions/ba/book/create.rb
class BA::Book::Create < BA::Book::BusinessAction
  # ...
  validates :title, presence: true
  # ...
end

And now we can properly test it:

require 'rails_helper'

RSpec.describe BA::Book::Create do
  subject(:action) { described_class.as(performer).new(attributes) }

  let(:performer) { User.new }
  let(:attributes) { { 'title' => 'Ruby Pickaxe'} }

  describe 'policies' do
    it { is_expected.to be_allowed }

    context 'when the user is not authorized' do
      let(:performer) { double }
      it { is_expected.not_to be_allowed }
    end
  end

  describe 'validations' do
    it { is_expected.to be_valid }

    context 'when preconditions fail' do
      let(:attributes) { { } }
      it { is_expected.not_to be_valid }
    end
  end
end

Perform

For now, the perform is a simple call to book.save! because Granite already assign the attributes.

Then we need to test if it's generating the right record:

require 'rails_helper'

RSpec.describe BA::Book::Create do
  subject(:action) { described_class.as(performer).new(attributes) }

  let(:performer) { User.new }
  let(:attributes) { { 'title' => 'Ruby Pickaxe'} }

  describe 'policies' do
    it { is_expected.to be_allowed }

    context 'when the user is not authorized' do
      let(:performer) { double }
      it { is_expected.not_to be_allowed }
    end
  end

  describe 'validations' do
    it { is_expected.to be_valid }

    context 'when preconditions fail' do
      let(:attributes) { { } }
      it { is_expected.not_to be_valid }
    end
  end

+   describe '#perform!' do
+     specify do
+       expect { action.perform! }.to change { Book.count }.by(1)
+       expect(action.subject.attributes.except('id', 'created_at', 'updated_at')).to eq(attributes)
+     end
+   end
end

The last step is to replace the current book creation in the controller to call the business action instead.

First thing is rescue from Granite::NotAllowed when some action is not allowed to be executed.

class BooksController < ApplicationController
  rescue_from Granite::Action::NotAllowedError do |exception|
    redirect_to books_path, alert: "You're not allowed to execute this action."
  end
  # ...
end

It will generically manage exceptions in case some unauthorized user tries to force acting without having access.

The next step is to wrap the method #create with the proper business action call.

class BooksController < ApplicationController

  # ...

  # POST /books
  def create
    book_action = BA::Book::Create.as(current_user).new(book_params)
      if book_action.perform
        redirect_to book_action.subject, notice: 'Book was successfully created.'
      else
        @book = book_action.subject
        render :new
      end
    end
  end

  # ...
end

Book::Rent action

To start renting the book, we need a few steps:

  1. Generate migration to create the rental table referencing the book and the user
  2. Add an available boolean column in the books table
  3. Create a business action Book::Rent and test the conditions above

Let's create Rental model first:

rails g model rental book:references user:references returned_at:timestamp

and add an available column in the books table:

rails g migration add_availability_to_books available:boolean

Now it's time to generate the next Granite action:

rails g granite book/rent

Preconditions

Let's write specs for the preconditions first:

RSpec.describe BA::Book::Rent do
  subject(:action) { described_class.as(performer).new(book) }

  let(:performer) { User.new }

  let(:book) { Book.new(title: 'First book', available: available) }

  describe 'preconditions' do
    context 'with an available book' do
      let(:available) { true }
      it { is_expected.to satisfy_preconditions }
    end

    context 'with an unavailable book' do
      let(:available) { false }
      it { is_expected.to be_invalid }
      it { is_expected.not_to satisfy_preconditions }
    end
  end
end

Preconditions are related to the book in the context. And the action will decline the context not to be executed if it does not satisfy the preconditions.

Let's implement the precondition and perform code:

class BA::Book::Rent < BA::Book::BusinessAction
+ precondition { book.available? }

  private

  def execute_perform!(*)
    Rental.create!(book: subject, user: performer)
    subject.available = false
    subject.save!
  end
end

Now, let's cover the perform with another spec:

RSpec.describe BA::Book::Rent do
  subject(:action) { described_class.as(performer).new(book) }

  let(:performer) { User.new }

  let(:book) { Book.new(title: 'First book', available: available) }

+ describe 'preconditions' do
+   context 'with an available book' do
+     let(:available) { true }
+     it { is_expected.to satisfy_preconditions }
+   end
+
+   context 'with an unavailable book' do
+     let(:available) { false }
+     it { is_expected.to be_invalid }
+     it { is_expected.not_to satisfy_preconditions }
+   end
+ end

  describe '#perform!' do
    specify do
      expect { action.perform! }
        .to change(book, :available).from(true).to(false)
        .and change(Rental, :count).by(1)
    end
  end
end

Book::Return action

First, think about the policies: to return the book, it needs to be rented by the person that is logged in.

Then we need to have a precondition to verify if the current book is being rented by this person:

class BA::Book::Return < BA::Book::BusinessAction
  precondition do
    rental_conditions = { book: subject, user: performer, returned_at: nil }
    Rental.where(rental_conditions).exists?
  end
end

The logic of the return, we just need to pick the current rental and assign the returned_at date. Also, make the book available again.

Let's start by testing the preconditions and guarantee that only the user that rent the book can return it.

RSpec.describe BA::Book::Return do
  subject(:action) { described_class.as(performer).new(book) }

  let(:book) { Book.create! title: 'Learn to fly', available: true }
  let(:performer) { User.create! }

  describe 'preconditions' do
    context 'when the user rented the book' do
      before { BA::Book::Rent.as(performer).new(book).perform! }
      it { is_expected.to satisfy_preconditions }
    end

    context 'when preconditions fail' do
      it { is_expected.not_to satisfy_preconditions }
    end
  end
end

And implementing the preconditions:

class BA::Book::Return < BA::Book::BusinessAction

  subject :book
  allow_if { performer.is_a?(User) }

  precondition do
    decline_with(:not_renting) unless performer.renting?(book)
  end
end

And the User now have a few scopes and the #renting? method:

class User < ApplicationRecord
  devise :database_authenticatable, :registerable
  has_many :rentals
  has_many :books, through: :rentals

  def renting?(book)
    rentals.current.where(book: book).exists?
  end
end

Now implementing the spec that covers the logic of return, is expected to make the book available and mark the rental with the given date.

RSpec.describe BA::Book::Return do
  subject(:action) { described_class.as(performer).new(book) }

  let(:book) { Book.create! title: 'Learn to fly', available: true }
  let(:performer) { User.create! }

  describe 'preconditions' do
    context 'when the user rented the book' do
      before { BA::Book::Rent.as(performer).new(book).perform! }
      it { is_expected.to satisfy_preconditions }
    end

    context 'when preconditions fail' do
      it { is_expected.not_to satisfy_preconditions }
    end
  end

+   describe '#perform!' do
+     let!(:rental) { Rental.create! book: book, user: performer }
+ 
+     specify do
+       expect { action.perform! }
+         .to change { book.reload.available }.from(false).to(true)
+         .and change { rental.reload.returned_at }.from(nil)
+     end
+   end
end

I18n

The last step to make it user-friendly and return a personalized message when the business action calls decline_with(:unavailable).

It's time to create the internationalization file for it.

File: config/locales/granite.en.yml

en:
  granite_action:
    errors:
      models:
        ba/book/rent:
          attributes:
            base:
              unavailable: 'The book is unavailable.'

Application layer

Great! Now it's time to change our views to allow people to interact with the actions we created.

First, we need to add controller methods to call the Rent and Return business actions and create routes for it.

class BooksController < ApplicationController

  # a few other scaffold methods here

  # POST /books/1/rent
  def rent
    @book = Book.find(params[:book_id])
    book_action = BA::Book::Rent.as(current_user).new(@book)
    if book_action.perform
      redirect_to books_url, notice: 'Enjoy the book!'
    else
      redirect_to books_url, alert:  book_action.errors.full_messages
    end
  end

  # POST /books/1/return_book
  def return_book
    @book = Book.find(params[:book_id])
    book_action = BA::Book::Return.as(current_user).new(@book)
      if book_action.perform
        redirect_to books_url, notice: 'Thanks for delivering it back.'
      else
        redirect_to books_url, alert:  book_action.errors.full_messages
      end
    end
  end
end

And add routes for rent and return_book in config/routes.rb:

  resources :books do
    post :rent
    post :return_book
  end

Now, it's time to change the current view to add such actions:

  <tbody>
    <% @books.each do |book| %>
      <tr>
        <td><%= book.title %></td>
        <% if book.available? %>
          <td><%= link_to 'Rent', rent_book_path(book), method: :post %></td>
        <% else %>
          <td>(Rented)</td>
        <% end %>
        <% if current_user && current_user.renting?(book) %>
           <td><%= link_to 'Return', return_book_path(book), method: :post %></td>
        <% end %>
        <td><%= link_to 'Show', book %></td>
        <td><%= link_to 'Edit', edit_book_path(book) %></td>
        <td><%= link_to 'Destroy', book, method: :delete, data: { confirm: 'Do you really want to destroy this book?' } %></td>
      </tr>
    <% end %>
  </tbody>

Now is a good opportunity to introduce projectors.

The actual implementation contains a few boilerplate code in the controller that make us repeat a few different logics that are already in the business action.

Projectors can help with that. Avoiding the need for creating repetitive controller methods and re-verify preconditions and policies to decide what actions can be executed.

Setup view context for Granite projector

You'll need to set up the master controller class. Let's create a file to configure what will be the base controller for Granite:

File: config/initializers/granite.rb

Granite.tap do |m|
  m.base_controller = 'ApplicationController'
end

The next step is to change ApplicationController to setup context view and allow Granite to inherit behavior from it.

app/controllers/application_controller

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception

  around_action :setup_granite_view_context
  before_action { view_context }

  protected

  def setup_granite_view_context(&block)
    Granite.with_view_context(view_context, &block)
  end
end

Inline projector

The current rent and returned_at methods have a very similar structure.

And the projectors allows to declare the HTTP method like get or post and mount it in a route as an anonymous controller.

Inside the block, the action is already set with all parameters from the web request and ready to be executed.

As the current controller actions are executed with POST, let's follow the same line and create a simple projector that allows to receive some post data and redirect to the resources list back.

The projector will have a default success_redirect and failure_redirect after the action execution. By default, let's assume that we'll redirect it to the collection and render a positive notice or a negative alert to the previous action.

class InlineProjector < Granite::Projector

  post :perform, as: '' do
    if action.perform!
      redirect_to projector.success_redirect, notice: t('.notice')
    else
      messages = projector.action.errors.full_messages.to_sentence
      redirect_to projector.failure_redirect, alert: t('.error', messages)
    end
  end

  def collection_subject
    action.subject.class.name.downcase.pluralize
  end

  def success_redirect
    h.public_send("#{collection_subject}_path")
  end

  def build_action(*args)
    action_class.with(self.class.proxy_context || {performer: h.current_user}).new(*args)
  end
end

We also need to say who is the performer of the action. The build_action method in the projector is implemented to override the current performer in action with the current_user.

Info

Note that h is an alias for view_context and you can access anything from the controller through it.

Now, it's time to say that we're going to use the projector inside the Rent action:

File: apq/actions/ba/book/rent.rb

class BA::Book::Rent < BaseAction
  subject :book

+  projector :inline

  allow_if { performer.is_a?(User) }

  precondition do
    decline_with(:unavailable) unless book.available?
  end

  private

  def execute_perform!(*)
    subject.available = false
    subject.save!
    Rental.create!(book: subject, user: performer)
  end
end

And also drop the method from the BooksController:

File: app/controllers/books_controller.rb

@@ -25,28 +25,6 @@ class BooksController < ApplicationController
     @book = Book.find(params[:id])
   end

-  # POST /books/1/rent
-  def rent
-    @book = Book.find(params[:book_id])
-    book_action = BA::Book::Rent.as(current_user).new(@book)
-    if book_action.perform
-      redirect_to books_url, notice: 'Book was successfully rented.'
-    else
-      redirect_to books_url, alert:  book_action.errors.full_messages.to_sentence
-    end
-  end

As the last step, we need to change the config/routes.rb to use the granite to mount the action#projector into the defined routes.

File: config/routes.rb

Rails.application.routes.draw do
  root 'books#index'

  devise_for :users

  resources :books do
-   post :rent
+   granite 'BA/book/rent#inline'
    post 'return', to: 'books#return_book', as 'return'
  end
end

Warning

As it's a tutorial, your next task is to do the same for return_book.

  1. Add projector :inline to BA::Book::Return class.
  2. Remove the controller method
  3. Refactor the config/routes.rb declaring the granite 'action#projector'

Projector Helpers

You can define useful methods for helping you rendering your view and improving the experience with your actions. Now, let's create a button function, to replace the action links in the current list.

First, we need to have a method in our projector that can render the button if the action is performable.

It will render nothing if the current user does not have access or it's an anonymous session.

We'll render the action name stricken if the action is not performable with the error messages in the title, because if people mouse over, they can see the "tooltip" with why it's not possible to execute the action.

class InlineProjector < Granite::Projector

  # ...
  # The previous methods remain here
  # ...

  def button(link_options = {})
    return unless action.allowed?
    if action.performable?
      h.link_to action_label, perform_path, method: :post
    end
  end

  def action_label
    action.class.name.demodulize.underscore.humanize
  end
end

And now, we can replace the links with the new button function:

  <tbody>
    <% @books.each do |book| %>
      <tr>
        <td><%= book.title %></td>
        <td><%= Ba::Book::Rent.as(current_user).new(book).inline.button%></td>
        <td><%= Ba::Book::Return.as(current_user).new(book).inline.button%></td>
        <td>... more links here ...</td>
      </tr>
    <% end %>
  </tbody>

Now it's clear, and the "Return" link will appear only for the user that rented the book.

Wishlist domain

As we are still working on the description of the wishlist domain, we encourage you to take the initiative and implement it yourself. We believe this will be a great opportunity for you to apply your own creativity and problem-solving skills, and we are always here to support you along the way.

Wishlist::Add action

To be done.

Wishlist::Remove action

To be done.

Wishlist::NotifyAvailability action

To be done.

Conclusion

To conclude, you have learned how to work with Granite, and we are excited to see you apply this knowledge to your upcoming project. We are confident that Granite will simplify your development process and allow you to focus on the more important aspects of your application. As always, please do not hesitate to reach out to us if you have any questions or need further assistance.