Separate your domain model from your persistence mechanism. Some problems call for a really sharp tool.
One, two! One, two! and through and through
The vorpal blade went snicker-snack!
He left it dead, and with its head
He went galumphing back.
- Jabberwocky by Lewis Carroll
Vorpal is a Data Mapper-style ORM (object relational mapper) framelet that persists POROs (plain old Ruby objects) to a relational DB. It has been heavily influenced by concepts from Domain Driven Design.
We say 'framelet' because it doesn't attempt to give you all the goodies that ORMs usually provide. Instead, it layers on top of an existing ORM and allows you to take advantage of the ease of the Active Record pattern where appropriate and the power of the Data Mapper pattern when you need it.
3 things set it apart from existing main-stream Ruby ORMs (ActiveRecord, Datamapper, and Sequel):
This last point is incredibly important because applications that grow organically can get very far without needing to separate persistence and domain logic. But when they do, Vorpal will play nicely with all that legacy code.
For more details on why we created Vorpal, see The Pitch.
Add this line to your application's Gemfile:
gem 'vorpal'
And then execute:
$ bundle
Or install it yourself as:
$ gem install vorpal
Start with a domain model of POROs and AR::Base objects that form an aggregate:
class Branch
attr_accessor :id
attr_accessor :length
attr_accessor :diameter
attr_accessor :tree
end
class Gardener < ActiveRecord::Base
end
class Tree
attr_accessor :id
attr_accessor :name
attr_accessor :gardener
attr_accessor :branches
end
In this aggregate, the Tree is the root and the Branches are inside the aggregate boundary. The Gardener is not technically part of the aggregate but is required for the aggregate to make sense so we say that it is on the aggregate boundary. Only objects that are inside the aggregate boundary will be saved, updated, or destroyed by Vorpal.
POROs must have setters and getters for all attributes and associations that are to be persisted. They must also provide a no argument constructor.
Along with a relational model (in PostgreSQL):
CREATE TABLE trees
(
id serial NOT NULL,
name text,
gardener_id integer
);
CREATE TABLE gardeners
(
id serial NOT NULL,
name text
);
CREATE TABLE branches
(
id serial NOT NULL,
length numeric,
diameter numeric,
tree_id integer
);
Create a repository configured to persist the aggregate to the relational model:
require 'vorpal'
module TreeRepository
extend self
engine = Vorpal.define do
map Tree do
attributes :name
belongs_to :gardener, owned: false
has_many :branches
end
map Gardener, to: Gardener
map Branch do
attributes :length, :diameter
belongs_to :tree
end
end
@mapper = engine.mapper_for(Tree)
def find(tree_id)
@mapper.query.where(id: tree_id).load_one
end
def save(tree)
@mapper.persist(tree)
end
def destroy(tree)
@mapper.destroy(tree)
end
def destroy_by_id(tree_id)
@mapper.destroy_by_id(tree_id)
end
end
Here we've used the owned: false
flag on the belongs_to
from the Tree to the Gardener to show
that the Gardener is on the aggregate boundary.
And use it:
# Saves/updates the given Tree as well as all Branches referenced by it,
# but not Gardeners.
TreeRepository.save(big_tree)
# Loads the given Tree as well as all Branches and Gardeners
# referenced by it.
small_tree = TreeRepository.find(small_tree_id)
# Destroys the given Tree as well as all Branches referenced by it,
# but not Gardeners.
TreeRepository.destroy(dead_tree)
# Or
TreeRepository.destroy_by_id(dead_tree_id)
Vorpal by default will use auto-incrementing Integers from a DB sequence for ids. However, UUID v4 ids are also supported:
Vorpal.define do
# UUID v4 id!
map Tree, primary_key_type: :uuid do
# ..
end
# Also a UUID v4 id, the Rails Way!
map Trunk, id: :uuid do
# ..
end
# If you feel the need to specify an auto-incrementing integer id.
map Branch, primary_key_type: :serial do
# ..
end
end
http://rubydoc.info/github/nulogy/vorpal
It also does not do some things that you might expect from other ORMs:
id
attribute is reserved for database primary keys. If you have a natural key/id on your domain model, name it something that makes sense for your domain. It is the strong opinion of the authors that using natural keys as foreign keys is a bad idea. This mixes domain and persistence concerns.Q. Why do I care about separating my persistence mechanism from my domain models?
A. It generally comes back to the Single Responsibility Principle. Here are some resources for the curious:
Q. How do I do more complicated queries against the DB without direct access to ActiveRecord?
A. Create a method on a Repository! They have full access to the DB/ORM so you can use Arel and go crazy or use direct SQL if you want.
For example, use the #query method on the AggregateMapper to access the underyling ActiveRecordRelation:
def find_special_ones
# use `load_all` or `load_one` to convert from ActiveRecord objects to domain POROs.
@mapper.query.where(special: true).load_all
end
Q. How do I do validations now that I don't have access to ActiveRecord anymore?
A. Depends on what kind of validations you want to do:
Q. How do I use Rails view helpers like form_for
?
A. Check out ActiveModel::Model. For more complex use-cases consider using a Form Object.
Q. How do I get dirty checking?
A. Check out ActiveModel::Dirty.
Q. How do I get serialization?
A. You can use ActiveModel::Serialization or ActiveModel::Serializers but they are not heartily recommended. The former is too coupled to the model and the latter is too coupled to Rails controllers. Vorpal uses SimpleSerializer for this purpose.
Q. Are updated_at
and created_at
supported?
A. Yes. If they exist on your database tables, they will behave exactly as if you were using vanilla ActiveRecord.
Q. How do I tell ActiveRecord to ignore certain columns so that I can rename/remove them using zero-downtime deploys?
A. You will want to use ActiveRecord's ignored_columns=
method like this:
engine = Vorpal.define do
map Product do
attributes(
:name,
:description,
# :column_to_remove
)
end
end
product_ar_class = engine.mapper_for(Product).db_class
product_ar_class.ignored_columns = [:column_to_remove]
git checkout -b my-new-feature
)git commit -am 'Add some feature'
)git push origin my-new-feature
)brew install direnv
)brew cask install docker
)CMD+space docker ENTER
)rbenv install 2.7.0
)brew install postgresql
)git clone git@github.com:nulogy/vorpal.git
) and cd
to the project root.gemfiles/rails_<version>.gemfile.lock
into a Gemfile.lock
file
at the root of the project. (cp gemfiles/rails_6_0.gemfile.lock gemfile.lock
)bundle
docker-compose up
rake
from the terminal to run all specs or rspec <path to spec file>
to
run a single spec.docker-compose up
appraisal rails-5-2 rake
from the terminal to run all specs or
appraisal rails-5-2 rspec <path to spec file>
to run a single spec.Please see the Appraisal gem docs for more information.
lib/vorpal/version.rb
appraisal install
Bump version to <X.Y.Z>
rake release
See who's contributed!