TECH

Trailblazer tutorial: move business logic from controller - part 2.

blogpost

Since we have basic cases and success flow tested and implemented, it is time to focus on our business logic which we want to move from controller to Operation.

Our first identified logic is to care about if event is closed or is about to be closed in 1 hour. So let's add tests to check if we will not be able to add a proposal in any of those cases (we already have a test for a success flow in which an event is opened, as described by the context name).

@event.closed? && @event.closes_at < 1.hour.ago

So lets add 2 tests and update “open case”

context 'with open event' do
  let!(:event) {
    Event.create(
      state: 'open', slug: 'Lorem', name: 'Ipsum',
      url: 'http://www.c.url', closes_at: Time.current + 1.day
    ) }
  ...
end

context 'with closed event' do
  let(:session_format) { instance_double(SessionFormat, id: 53) }
  let!(:event) { Event.create(state: 'closed', slug: 'Lorem', name: 'Ipsum', url: 'http://www.c.url') }
  it 'returns result object with an error about closed event without saving the proposal' do
    expect(result[:errors]).to eq('Event is closed')
    expect(result).to be_failure
  end
end


context 'with event that is about to be closed' do
  let(:session_format) { instance_double(SessionFormat, id: 53) }
  let!(:event) {
    Event.create(
      state: 'open', slug: 'Lorem', name: 'Ipsum',
      url: 'http://www.c.url', closes_at: Time.current + 40.minutes
    ) }

  it 'returns result object with an error about closed event without saving the proposal' do
    expect(result[:errors]).to eq('Event is closed')
    expect(result).to be_failure
  end
end

And check if they pass:

$ rspec spec/concepts/proposal/operation/create_spec.rb
..FF
Failures:

1) Proposal::Operation::Create with valid params with closed event returns result object with an error about closed event without saving the proposal
Failure/Error: expect(result[:errors]).to eq("Event is closed")
expected: 'Event is closed'
got: nil

2) Proposal::Operation::Create with valid params with event that is about to be closed returns result object with an error about closed event without saving the proposal
Failure/Error: expect(result[:errors]).to eq('Event is closed')
expected: 'Event is closed'
got: nil

Finished in 0.43801 seconds (files took 8.64 seconds to load)
4 examples, 2 failures

That wasn’t unexpected since we didn’t implement handling this case. Let’s do that.

(Iteration 5) Add handling “event closed or about to be closed” case.

We need to add a step checking if the event is opened, and a method to handle failure flow.

module Proposal::Operation
  class Create < Trailblazer::Operation
    class Present < Trailblazer::Operation
      step Model(Proposal, :new)
      step Contract::Build(constant: Proposal::Contract::Create)
      step Contract::Validate(key: :proposal) 
    end

    step Nested(Present)
    step :event
    fail :event_not_found
    # step for checking if event is opened
    step :event_open?
    # step for handling failure
    fail :event_not_open_error
    step :assign_event
    step Contract::Persist()

    def event(ctx, params:, **)
      ctx[:event] = Event.find_by(slug: params[:event_slug])
    end

    def assign_event(ctx, **)
      ctx[:model].event = ctx[:event]
    end

    # we check if event.open? Method is true, and if event will be open for next hour
    def event_open?(ctx, **)
      ctx[:event].open? && ctx[:event].closes_at >= 1.hour.since
    end

    # -- bad stuff handled there --
    def event_not_found(ctx, **)
      ctx[:errors] = "Event not found"
    end

    def event_not_open_error(ctx, **)
      ctx[:errors] = "Event is closed"
    end
  end
end

Should all of our tests be green now? Let's check:

$ rspec spec/concepts/proposal/operation/create_spec.rb
FF..
Failures:

1) Proposal::Operation::Create with valid params with open event creates a proposal assigned to event identified by slug
Failure/Error: ctx[:event].open? && ctx[:event].closes_at >= 1.hour.since

NoMethodError:
undefined method `>=' for nil:NilClass

2) Proposal::Operation::Create with valid params without event returns result object with an error about closed event without saving the proposal
Failure/Error: expect(result[:errors]).to eq("Event not found")

expected: 'Event not found'
got: 'Event is closed'

Finished in 1.37 seconds (files took 12.98 seconds to load)
4 examples, 2 failures

So what can we see, is regression - newly implemented feature, broke our previous tests? Why is that? Let's try to debug “no event” case by one of the coolest TRB features which are #wtf? method:

context 'without event' do
  let(:session_format)  { instance_double(SessionFormat, id: 53) }
  it 'returns result object with an error about closed event without saving the proposal' do
    binding.pry
    expect(result[:errors]).to eq('Event not found')
    expect(result).to be_failure
  end
end

pry(...)> result.wtf?

`-- Proposal::Operation::Create
|-- Start.default
|-- Nested(Proposal::Operation::Create::Present)
| |-- Start.default
| |-- model.build
| |-- contract.build
| |-- contract.default.validate
| | |-- Start.default
| | |-- contract.default.params_extract
| | |-- contract.default.call
| | `-- End.success
| `-- End.success
|-- event
|-- event_not_found
|-- event_not_open_error
`-- End.failure

As you can see, #wtf? method called on our result, gave us a really explicit tree of what was called when and how it was nested. If we will track what happened, we can see that after the method event was called, fail step event_not_found was called, and then every other (in our case it's just event_not_open_error) fail step was also called.

To understand it we need to fully understand what keyword trail in Trailblazer means, and how it works. And that will be the most important knowledge from this blogpost. http://trailblazer.to/gems/operation/2.0/index.html - Flow Control section should explain basics.
But what else we have in our case? We have another fail step in the failure trail. So after calling event_not_found which calls:

ctx[:errors] = 'Event not found'

Then we call event_not_open_error which calls

ctx[:errors] = 'Event is closed'

and that overwrite our [:errors] value.

Since sometimes we need to exit from failure trail and just stop operation, and sometimes we want to run all further fail steps, TRB comes with handy method:

fail :event_not_found, fail_fast: true

After we change fail_fast value to true, whole operation logic will be stopped on the fail trail after calling failing method event_not_found in this case.

So our Operation flow will look like:

step Nested(Present)
step :event
fail :event_not_found, fail_fast: true
step :event_open?
fail :event_not_open_error
step :assign_event
step Contract::Persist()

And running the tests will result in:

$ rspec spec/concepts/proposal/operation/create_spec.rb
....

Finished in 0.48573 seconds (files took 8.7 seconds to load)
4 examples, 0 failures

Read more on our blog

Check out the knowledge base collected and distilled by experienced
professionals.
bloglist_item
Tech

Over the years I had to deal with applications and system that have a long history of already being "legacy".
On top of that I met with clients/product owners that never want you to spend time ref...

bloglist_item
Tech

How many times have you searched for that one specific library that meets your needs? How much time have you spent customizing it to fit your project's requirements? I must admit, waaay too much. T...