Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial Implementation #1

Merged
merged 13 commits into from
Sep 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: CI

on:
- push
- pull_request

jobs:
test:
name: Ruby ${{ matrix.ruby }} / Rails ${{ matrix.rails }}
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest
ruby:
- 3.0
- 3.1
- 3.2
gemfile:
- rails-7.0
allow_failures:
- false
runs-on: ${{ matrix.os }}
env:
BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Ruby ${{ matrix.ruby }}
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
ruby-version: ${{ matrix.ruby }}
- name: Run tests
run: bundle exec rake spec
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,11 @@
/pkg/
/spec/reports/
/tmp/
/cache/
/gems/
/specifications/
.byebug_history
development.log
/Dockerfile
*.DS_Store
*.swp
1 change: 1 addition & 0 deletions .rspec
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
--format documentation
--color
--require spec_helper
4 changes: 0 additions & 4 deletions .travis.yml

This file was deleted.

7 changes: 6 additions & 1 deletion Gemfile
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
source 'https://rubygems.org'
source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

gem "activemodel", "~> 7.0"

gemspec
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
The MIT License (MIT)

Copyright (c) 2016 Bryan Rite
Copyright (c) 2023 Bryan Rite

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
109 changes: 102 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
# Operational

Operational is a work in progress, currently being extracted from several complex rails apps into this more generic gem. It is pre-release at the moment, so there is nothing to see here.
Applications usually start out simple, actions are largely CRUD-y and isolated to a single database backed model. As they grow and complicated business logic creeps in, something to orchestrate the action helps to keep concerns separated and decouple business logic from data persistence.

Read more about Operational's motivations here: https://bryanrite.com/simplifying-complex-rails-apps-with-operations/
This is what **Operational** attempts to solve.

Operational introduces the concepts of functional _Operations_ and _Form Objects_, to solve these problems, relying on Ruby on Rails' ActiveModel to keep the code **small** and **dependency free**. This enables very powerful organization of code with a light touch.

This gem is heavily inspired by [Trailblazer](https://github.com/trailblazer/trailblazer) and [dry-rb](https://dry-rb.org/), both of which I have used extensively for many years. Operational solves a similar but more focused set of problems and relies on ActiveModel and other Rails conventions, rather than being framework agnostic; meaning there is far less code, moving parts, and no dependencies.

Read more about Operational's motivations here: [https://bryanrite.com/simplifying-complex-rails-apps-with-operations/](https://bryanrite.com/simplifying-complex-rails-apps-with-operations/)

## Installation

Expand All @@ -20,15 +26,104 @@ Or install it yourself as:

$ gem install operational

## Usage

TODO: Write usage instructions here
## Guide

The main concepts introduced by Operational are:

**Operations** are an orchestrator, or wrapper, around a business process. They don't _do_ a lot themselves but provide an interface for executing all the business logic in an action while keeping the data persistence and service objects isolated and decoupled.

**Railway Oriented/Monad Programming** is a functional way to define the business logic steps in an action, simplifying the handling of happy paths and failure paths in easy to understand tracks. You define steps that execute in order, the result of the step (truthy or falsey) moves execution to the success or failure tracks. Reduce complicated conditionals with explict methods.

**Functional State** is the idea that each step in an operation receives parameters from the previous step, much like Unix pipe passing output from one command to the next. This state is a single hash that allows you to encapsulate all the information an operation might need, like `current_user`, `params`, or `remote_ip` and provide it in a single point of access. It is mutable within the execution of the operation, so steps may add to it, but immutable at the end of the operation.

**Form Objects** help decouple data persistence from your view. They allow you to define a form or API that may touch many different ActiveRecord models without needing to couple those models together. Validation can be done in the form, where it is more contextually appropriate, rather than on the model. Form Objects more securely define what attributes a request may submit without the need for `StrongParameters`, as they are not directly database backed and do not suffer from mass assignment issues StrongParameters tries to solve.


## Getting Started

Bringing the above concepts to bear, a typical Rails action using Operational may look like:

```ruby
# app/controllers/todos_controller.rb
class TodosController < ActionController::Base
include Operational::Controller

# By default, params and current_user are set to the state for each operation, this can
# be overridden at the controller level.

def new
# Run the Present part of the operation, which typically sets up the model and form.
run Todos::CreateOperation::Present
end

def create
# Run the entire operation, the result of the operation decides what to do, rather
# than, as in typical Rails fashion, whether the model saved or not.
if run Todos::CreateOperation
return redirect_to todo_path(@state[:todo].id), notice: "Created Successfully."
else
render :new
end
end
end

# app/concepts/todos/create_form.rb
module Todos
class CreateForm < Operational::Form
# Define attributes and type that this form will accept. Replaces the implicitly
# defined ActiveRecord attributes with an explicit list not coupled to any model.
# Strong Parameters is no longer required.
attribute :description, :string

# Define active model validations as normal.
validates :description, presence: true, length: { maximum: 500 }
end
end

# app/concepts/todos/create_operation.rb
module Todos
class CreateOperation < Operational::Operation

# Create a model(s), build a form object.
class Present < Operational::Operation
step :setup_new_model
step Contract::Build(contract: CreateForm, model_key: :todo)

def setup_model(state)
state[:todo] = Todo.new(user: state[:current_user])
end
end

# Run the Present part.
step Nested::Operation(operation: Present)
# Validiate the form, if validation fails, railway stops here, operation returns
# false and controller re-renders :new action.
step Contract::Validate()
# Sync the valid attributes from the form back to state
step Contract::Sync(model_key: :todo)
# Run some additional steps like persisting data, emiting events, notifying
# internal systems, etc.
step :persist
step :send_notifications

def persist(state)
state[:todo].save!
end

def send_notifications(state)
# ...
end
end
```

_Theres a heck of a lot more, but I'll get to that soon in a proper detailed Wiki guide._


## Development
## RDocs

After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
To come.

To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).

## Contributing

Expand Down
14 changes: 0 additions & 14 deletions bin/console

This file was deleted.

8 changes: 0 additions & 8 deletions bin/setup

This file was deleted.

7 changes: 7 additions & 0 deletions gemfiles/rails-7.0.gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

gem "activemodel", "~> 7.0.0"

gemspec path: "../"
10 changes: 10 additions & 0 deletions lib/operational.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
require 'active_model'

require "operational/version"
require "operational/error"
require "operational/operation"
require "operational/result"
require "operational/form"
require "operational/controller"

require "operational/operation/contract"
require "operational/operation/nested"

module Operational
end
26 changes: 26 additions & 0 deletions lib/operational/controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
module Operational
module Controller
def run(operation, **extras)
state = (extras || {}).merge(_operational_default_state)
result = operation.call(state)

@_operational_result = result
instance_variable_set(_operational_state_variable, result.state)

return result.succeeded?
end

protected

def _operational_state_variable
:@state
end

def _operational_default_state
{}.tap do |hash|
hash[:current_user] = current_user if self.respond_to?(:current_user)
hash[:params] = params if self.respond_to?(:params)
end
end
end
end
6 changes: 6 additions & 0 deletions lib/operational/error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module Operational
class Error < StandardError; end
class InvalidContractModel < Error; end
class MethodNotImplemented < Error; end
class UnknownStepType < Error; end
end
21 changes: 21 additions & 0 deletions lib/operational/form.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
module Operational
class Form
include ActiveModel::Model
include ActiveModel::Attributes
include ActiveModel::Dirty

def persisted?
@_operational_model_persisted
end

def other_validators_have_passed?
errors.blank?
end

protected

def _operational_state_variable
:@state
end
end
end
65 changes: 65 additions & 0 deletions lib/operational/operation.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
module Operational
class Operation
def self.step(action)
add_step(:step, action)
end

def self.pass(action)
add_step(:pass, action)
end

def self.fail(action)
add_step(:fail, action)
end

def self.call(state={})
instance = self.new
instance.instance_variable_set(:@_operational_state, state)
instance.instance_variable_set(:@_operational_path, [])
instance.instance_variable_set(:@_operational_succeeded, true)

failure_circuit = false

instance.send(:_operational_steps).each do |railway_step|
type, action = railway_step

next if !failure_circuit && type == :fail
next if failure_circuit && type != :fail

result = if action.is_a?(Symbol)
instance.send(action, instance.send(:_operational_state))
elsif action.respond_to?(:call)
action.call(instance.send(:_operational_state))
else
raise UnknownStepType
end

instance.instance_variable_set(:@_operational_succeeded, result ? true : false)
instance.instance_variable_get(:@_operational_path) << (result ? true : false)

failure_circuit = !instance.instance_variable_get(:@_operational_succeeded) && type != :pass
end

return Result.new(
succeeded: (instance.instance_variable_get(:@_operational_succeeded) ? true : false),
state: instance.instance_variable_get(:@_operational_state),
operation: instance)
end

private

def self.add_step(type, action)
railway = class_variable_defined?(:@@_operational_steps) ? class_variable_get(:@@_operational_steps) : []
railway << [type, action]
class_variable_set(:@@_operational_steps, railway)
end

def _operational_steps
self.class.class_variable_defined?(:@@_operational_steps) ? self.class.class_variable_get(:@@_operational_steps) : []
end

def _operational_state
@_operational_state
end
end
end
Loading