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.
- It keeps persistence concerns separate from domain logic. In other words, your domain models don't have to extend ActiveRecord::Base (or something else) in order to get saved to a DB.
- It works with Aggregates rather than individual objects.
- It plays nicely with ActiveRecord objects!
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:
And then execute:
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 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 = . 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:
. 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
It also does not do some things that you might expect from other ORMs:
- No lazy loading of associations. This might sound like a big deal, but with correctly designed aggregates it turns out not to be.
- No managing of transactions. It is the strong opinion of the authors that managing transactions is an application-level concern.
- No support for validations. Validations are not a persistence concern.
- No AR-style callbacks. Use Infrastructure, Application, or Domain services instead.
- No has-many-through associations. Use two has-many associations to a join entity instead.
idattribute 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.
- Persisted entities must have getters and setters for all persisted attributes and associations. They do not need to be public.
- Only supports PostgreSQL.
- Support for having foreign keys point to columns other than primary keys.
- Support for storing entity ids in a column called something other than "id".
- Nicer DSL for specifying attributes that have different names in the domain model than in the DB.
- Aggregate updated_at.
- Better support for value objects.
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:
- Architecture, the Lost Years - talk by Bob Martin.
- Hexagonal Architecture Pattern - Alistair Cockburn.
- Perpetuity - a Ruby Data Mapper-style ORM.
- EDR - another Data Mapper-style ORM framelet by Victor Savkin.
Q. How do I do more complicated queries against the DB without direct access to ActiveRecord?
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:
- For validating single attributes on a model: ActiveModel::Validations work very well.
- For validating whole objects or object compositions (like the state of an Aggregate): Validator objects are preferred. Chapter 5 of Implementing Domain Driven Design contains more guidance.
Q. How do I use Rails view helpers like
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.
A. Yes. If they exist on your database tables, they will behave exactly as if you were using vanilla ActiveRecord.
- Fork it ( https://github.com/nulogy/vorpal/fork )
- Create your feature branch (
git checkout -b my-new-feature)
- Commit your changes (
git commit -am 'Add some feature')
- Push to the branch (
git push origin my-new-feature)
- Create a new Pull Request
OSX Environment setup
- Install Homebrew
- Install rbenv (RVM can work too)
- Install DirEnv (
brew install direnv)
- Install Docker Desktop Community Edition (
brew cask install docker)
- Start Docker Desktop Community Edition (
CMD+space docker ENTER)
- Install Ruby (
rbenv install 2.7.0)
- Install PostgreSQL (
brew install postgresql)
- Clone the repo (
git clone [email protected]:nulogy/vorpal.git) and
cdto the project root.
- Copy the contents of
Gemfile.lockfile at the root of the project. (
cp gemfiles/rails_6_0.gemfile.lock gemfile.lock)
- Start a PostgreSQL server using
rakefrom the terminal to run all specs or
rspec <path to spec file>to run a single spec.
Running Tests for a specific version of Rails
- Start a PostgreSQL server using
appraisal rails-5-2 rakefrom 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.
- Update the version number in
- Update the version of Vorpal in the Appraisal gemfiles (otherwise Travis CI will fail):
- Commit the above changes with the message:
Bump version to <X.Y.Z>
- Release the new version to Rubygems:
See who's contributed!