Conjunction
Join together related concepts for a common purpose with Conjugation.
Installation
Add this line to your application's Gemfile:
gem 'conjunction'
And then execute:
$ bundle
Or install it yourself as:
$ gem install conjunction
QuickStart Guide
Let's say you have an Order
:
class Order < ApplicationRecord
def self.service_class
OrderService
end
end
Which descends from an ApplicationRecord
:
class ApplicationRecord
def to_service
self.class.service_class.new(self)
end
end
And you have an OrderService
:
class OrderService < ApplicationService
# ...software be here...
end
Which descends from an ApplicationService
:
class ApplicationService
def initialize(object)
@object = object
end
end
Use Conjunction
to tell your Records they can be related (called a Conjunctive):
class ApplicationRecord < ActiveRecord::Base
include Conjunction::Conjunctive
end
And your services that they are a kind of relation with a naming convention (called a Junction):
class ApplicationService
include Conjunction::Junction
prefixed_with "Service"
end
Now your can look up related objects (like services) implicitly from your objects:
class ApplicationRecord
def to_service
conjugate(ApplicationService)&.new(self)
end
end
And remove all the implicit boilerplate which could be assumed:
class Order < ApplicationRecord; end
Order.conjugate(ApplicationService) # => OrderService
And then in the future, any new objects you create which follow convention "just work":
class Foo < ApplicationRecord; end
class FooService < ApplicationService; end
Foo.conjugate(ApplicationService) # => FooService
You can also quickly an easily configure relationships explicitly, either directly:
class Foo < ApplicationRecord
conjoins BarService
end
Foo.conjugate(ApplicationService) # => BarService
Or through a central routing file called a Nexus
:
# config/initializers/conjunction_nexus.rb
class Conjunction::Nexus
couple Foo, to: GazService
end
Foo.conjugate(ApplicationService) # => GazService
You may also be interested in reading through the Configuration docs.
Gem Developer Usage
🚨 Note: This is a middleware gem designed to help gem developers or folks with lots of custom DSL objects build them in a cleaner and more standardized way. It is NOT expected that most application developers will need to be aware of this gem's existence or configuration!
On the Separation of Concerns
Consider the following:
class User < ApplicationRecord
validates :first_name, :last_name, length: { minimum: 2 }, presence: true
def initials
"#{first_name.chr}#{last_name.chr}"
end
def name
"#{first_name} #{last_name}"
end
# conventional name in application for displaying in subjects in views
def display_name
name
end
# name used when this user sends emails to others on platforms
def email_from_name
name
end
# name used when addressing this user in emails (obviously...)
def email_to_name
first_name
end
# vestigial code used in the legacy half of the app... smh
def nickname
last_name
end
end
This object has very poor Separation of Concerns; it obligates much of the application while providing little value at the cost of extraneous code.
class User < ApplicationRecord
include PersonNameable
include NamedPresentable
include NamedPersonEmailAddressable
# vestigial code used in the legacy half of the app... smh
def nickname
last_name
end
end
This kinda looks nicer. Until you look at the consequential under-the-hood code:
module PersonNameable
extend ActiveSupport::Concern
included do
validates :first_name, :last_name, length: { minimum: 2 }, presence: true
end
def initials
"#{first_name.chr}#{last_name.chr}"
end
def name
"#{first_name} #{last_name}"
end
end
module NamedPresentable
extend ActiveSupport::Concern
# conventional name in application for displaying in subjects in views, assume name
def display_name
name
end
end
module NamedPersonEmailAddressable
extend ActiveSupport::Concern
# name used when this user sends emails to others on platforms
def email_from_name
name
end
# name used when addressing this user in emails (obviously...)
def email_to_name
first_name
end
end
And recognize that user.email_to_name
is still a valid method. This object STILL has very poor Separation of Concerns
but in this form also suffers from a much higher Connascence! OH NO! 😭
⭐️ That's because Separation of Concerns applies to your object architecture NOT your file system!
An object architecture with properly separated concerns would look more like this:
class User
validates :first_name, :last_name, length: { minimum: 2 }, presence: true
def initials
"#{first_name.chr}#{last_name.chr}"
end
def name
"#{first_name} #{last_name}"
end
# vestigial code used in the legacy half of the app... smh
def nickname
last_name
end
end
class UserPresenter
def initialize(user)
@user = user
end
def display_name
user.name
end
end
class UserEmailSender
def initialize(user)
@user = user
end
def from_name
user.name
end
end
class UserEmailRecipient
def initialize(user)
@user = user
end
def to_name
user.first_name
end
end
Now, the User
object has no need to know about email sending or receiving, nor whatever naming standards the application has adopted around presenting objects to users.
This is a nominal pattern adopted by several gems in the rails ecosystem: ActiveModelSerializers, Draper, Pundit to name a few.
Enter Conjunction
, a gem for managing the coupling of properly separated object concerns.
Why does this exist?
Let's imagine we're building a standardized presenter gem for displaying models:
class ApplicationPresenter
def initialize(model)
@model = model
end
def name
I18n.t("unknown")
end
end
And we want to create a specialized class for displaying a specific model:
class UserPresenter < ApplicationPresenter
def name
I18n.t("format_greetings.#{user.greeting_type}", user.name)
end
end
So the first question is how a User
know about it's presenter.
One option is direct reference:
class User < ApplicationRecord
def to_presenter
UserPresenter.new(self)
end
end
This leverages Connascence of Name (CoN)
which is the weakest (and therefore most ideal) reference.
And this solution is nice, but it gets kind of messy at scale:
class User < ApplicationRecord
def to_presenter
UserPresenter.new(self)
end
def to_serializer
UserSerializer.new(self)
end
def to_policy
UserPolicy.new(self)
end
end
Especially when you add in the complexity of class AND/OR instance reference:
class User < ApplicationRecord
class << self
def to_presenter
UserList.new
end
def to_policy
UserPolicy.new
end
end
def to_presenter
UserPresenter.new(self)
end
def to_serializer
UserSerializer.new(self)
end
def to_policy
UserPolicy.new(self)
end
end
For just three related objects we're at 20 lines of code added to potentially N
models in the ecosystem.
This also creates other problems, such as dissimilarity in reference, a model without a presenter will not define a to_presenter
method, so generic code:
model.to_presenter # => raise NoMethodError ?!
To get around this, you now need to put some kind of generic handling in the base object:
class ApplicationRecord
class << self
def to_presenter
nil
end
def to_policy
raise "all records must have a policy"
end
end
def to_presenter
null
end
def to_serializer
raise "all records must have a serializer"
end
def to_policy
raise "all records must have a policy"
end
end
This also obviously isn't a possibility if you are writing a third party gem to introduce a "kind" of object into the system, and the example gems have all solved this problem differently:
ActiveModelSerializers
conjured up the LookupChain, Draper
went the concern route with decoratable and Pundit
evolved the PolicyFinder.
All these are very disparate and feature-rich implementations of a solution to the problem. All of them offering at least some configurability to control how the lookup occurred to translate the kind of "root" model object into its related SoC object.
So ultimately, Conjunction
exists because I thought it would be nice to create a generic solution to this object reference problem that can be utilized by other gems to create some kind of standardization and consistency to this hard problem.
It also selfishly helps cleanup a lot of duplicate code across several co-developed gems which I will shamelessly plug here: Command, Facet, Flow, Law, Material.
How's it Function?
In Conjunction
there are two distinct concepts: Prototypes and Conjugates.
A Prototype is unitary (named without a prefix or suffix) and represents the core object around which concerns are being separated, in the above example User
.
A Conjugate is the SoC
object which encapsulates the other behaviors or information, in the above example UserPresenter
; named after the linguistic process that gives the different forms of an verb as they vary according to voice, mood, tense, etc.
Generally speaking, the community assumption peddled by the ActiveModelSerializers, Drapers, and Pundits of the world seems to be a suffixed naming convention; AuthorSerializer
for an Author
class, ArticleDecorator
for an Article
class, UserPolicy
for a User
class.
Conjunction
defines a .conjugate
Prototypes class method to allow the lookup of Conjugate objects given their base classes:
Author.conjugate(ApplicationSerializer) # => AuthorSerializer
Article.conjugate(ApplicationDecorator) # => ArticleDecorator
User.conjugate(ApplicationPolicy) # => UserPolicy
Digging In
Conjunction
leverages two main concerns: Conjunctives and Junctions.
A Conjunctive is the generic base class from which your Prototype descends, in the above example ApplicationRecord
is the Conjunctive for the User
Prototype.
class ApplicationRecord
include Conjunction::Conjunctive
end
This grants ApplicationRecord
the ability to represent itself as a Prototype, the primary requirement of which is to define a .prototype_name
method (which by default is simply the class name):
User.prototype_name # => User
User.first.prototype_name # => User
A Junction is the generic base class from which any Conjugate object descends, in the above example ApplicationPresenter
is the Junction for the UserPresenter
Conjugate.
class ApplicationPresenter
include Conjunction::Junction
end
This grants ApplicationPresenter
the ability to reference a Prototype and find a related Conjugate; also it allows you to write Conjunction::Junction
and commit it into a production application for serious, which is a total bonus feature 🤩.
The primary requirement of a Junction
is to define a .junction_key
which should be unique among your SoC
objects.
🚨 WARNING: By default, your Junctions WILL NOT define a junction key!
You can explicitly define a junction key:
class ApplicationPresenter
include Conjunction::Junction
class << self
def junction_key
:any_key_u_want
end
end
end
😽 Super Lazy??: Good News, so am I! There are default junction keys!
On Naming Conventions
Given the community standard of FooBarThing
naming convention for Things
, there is an assumption that most applications will attempt to keep a minimal Connascence of Name (CoN)
approach; usually for any given FooBar
you would expect it's Thing
to be a FooBarThing
.
You can and should define your expected naming convention on your Junctions:
class ApplicationPresenter
include Conjunction::Junction
suffixed_with "Presenter"
end
This now gives the Presenter
junction enough information to know that it's naming convention is #{prototype_name}Presenter
. It also provides enough unique identifying information to assume the junction_key
as an underscored version of the suffix:
ApplicationPresenter.junction_key # => presenter
You can also use prefixed_with
if you want to do namespaces instead, ex:
class ApplicationFleeb
include Conjunction::Junction
prefixed_with "Grundus::Fleeb::"
end
This now assumes that a Foo
prototype will have a Grundus::Fleeb::Foo
conjugate.
💁 Note: You can use both a suffix
and prefix
together; the junction key will be #{suffix}_#{prefix}
.
You can also override the default by setting it to a blank string in a descendant class:
class PresenterBase
include Conjunction::Junction
suffixed_with "Presenter"
end
class ApplicationPresenter < Base::Presenter
suffixed_with ""
prefixed_with "Presenter::"
end
Backreference
Naming Conventions are necessary to facilitate "best guess lookup" and therefore if a Conjugate name can be intuited by the naming convention, a Prototype name can be distilled from it:
UserPresenter.prototype_class # => User
Grundus::Fleeb::User.prototype_class # => User
This allows valuable class level introspection into relationships which can be quickly and easily put under test:
RSpec.describe User do
it { is_expected.to conjugate_into UserPresenter }
it { is_expected.to conjugate_into Grundus::Fleeb::User }
end
From either side of the equation (ideally, both!):
RSpec.describe UserPresenter do
it { is_expected.to be_conjugated_from User }
end
RSpec.describe Grundus::Fleeb::User do
it { is_expected.to be_conjugated_from User }
end
Bi-direction Cross-Reference
An even more interesting side-effect of backreference in the nominal case is Bi-direction Cross-Reference. Two Junctions which are conjugates of a given prototype can be directly conjugated into each other!
UserPresenter.conjugate(ApplicationFleeb) # => Grundus::Fleeb::User
Grundus::Fleeb::User.conjugate(ApplicationPresenter) # => UserPresenter
What's really interesting is that you don't even need a user class to really exist for this kind of behavior to manifest! The above snippet works even when defined?(User) == false
!!
Configuration
Conjunction
expects that a well-thought-out (or at least standardized) naming convention can get you a long way, but given software is hard there must be some mechanism for overrides.
So it might surprise you to find that no mechanism has been exposed to override Conjunction
!
It will always run in implicit lookup mode where it attempts to use the naming convention of objects as it understands them to intuit the coupling relationships of your application.
It can be configured NOT to perform implicit lookup at all if you hate the idea of magic names "just working":
# config/initializers/conjunction.rb
Conjunction.configure do |config|
config.disable_all_implicit_lookup = true
end
Explicit vs Override
Instead of overriding implicit lookup, you can simply explicitly define conjunctions.
To directly tell any Conjunctive how it is related to its conjugates, use .conjoins
:
class User < ApplicationRecord
conjoins UserPresenter
conjoins Grundus::Fleeb::User
conjoins UserPolicy
end
⚠️ WARNING: You can only provide objects which are valid Conjunction::Junctions
to the .conjoins
method; any object without a valid junction_key
will raise an exception.
It is this explicit reference mechanism that exists for you to "override" the default coupling rules of your application:
class User < ApplicationRecord
conjoins GenericPresenter
conjoins Grundus::Fleeb::Dinglebop
conjoins AdminOnlyEditOpenViewPolicy
end
This has the direct and immediate effect of altering what conjugates are returned:
User.conjugate(ApplicationPresenter) # => GenericPresenter
User.conjugate(ApplicationFleeb) # => Grundus::Fleeb::Dinglebop
User.conjugate(ApplicationPolicy) # => AdminOnlyEditOpenViewPolicy
This is true EVEN IF you have valid naming-convention-assumed objects that exist. For example, even if there was a UserPresenter
the immediate example above would return a GenericPresenter
. This can best be seen in this example:
User.conjugate(UserPresenter) # => GenericPresenter
Even giving the object what you assume the destination presenter would be, it's conjugate follows the explicit naming rules!
💁 Note: You can use .conjoins
only when you need to override the otherwise "default" implicit naming conventions. This "explicit as override" methodology is the recommended way to use Conjunction
, as it calls attention to "what's different" while letting normal "ust work". It also prevents you from having to define an object AND remember to relate it.
class User < ApplicationRecord
conjoins AdminOnlyEditOpenViewPolicy
end
Given an expected presence of other objects, this would work as follows:
User.conjugate(ApplicationPresenter) # => UserPresenter
User.conjugate(ApplicationFleeb) # => Grundus::Fleeb::User
User.conjugate(ApplicationPolicy) # => AdminOnlyEditOpenViewPolicy
Nexus
In what is arguably the coolest class name I've ever written, you can define a central routing nexus which acts as a central source of truth for all the object relationships in your application.
Create a config/initializers/conjunction_nexus.rb
file like so:
class Conjunction::Nexus
couple Foo, to: CommonMaterial
couple Bar, to: CommonMaterial
couple FooFlow, to: FooState, bidirectional: true
end
The bidirectional: true
flag above is the DRY form of this equivalent assignment:
class Conjunction::Nexus
couple FooFlow, to: FooState
couple FooState, to: FooFlow
end
The Nexus
file is a "best of both worlds" approach if you want to keep your models (conjunctives) limited in what they outwardly need to know about their other conjunctive forms without bloating each model individually.
💁 Note: If you make use of the nexus file and want to enforce explicit lookup behavior in your application, there is a special configuration option to disable implicit lookup for any classes which are defined within the nexus using the nexus_use_disables_implicit_lookup
flag:
# config/initializers/conjunction.rb
Conjunction.configure do |config|
config.nexus_use_disables_implicit_lookup = true
end
Now, if you define something within the nexus for any given type of junction, ALL junctions need to follow suite:
# Given `FooPresenter`, `BarPresenter`, `Foo`, and `Bar`:
class Conjunction::Nexus
couple Foo, to: FooPresenter
end
# With `nexus_use_disables_implicit_lookup` false:
Foo.conjoins(ApplicationPresenter) # => FooPresenter
Bar.conjoins(ApplicationPresenter) # => BarPresenter
# With `nexus_use_disables_implicit_lookup` true:
Foo.conjoins(ApplicationPresenter) # => FooPresenter
Bar.conjoins(ApplicationPresenter) # => nil
💁 Note: Nexus configuration is a "kind" of explicit configuration and shouldn't be mixed with conflicting information on the object itself:
class Conjunction::Nexus
couple Foo, to: FooPresenter
end
class Foo < ApplicationRecord
conjoins BarPresenter
end
In the above example, Foo.conjoins(ApplicationPresenter)
would be BarPresenter
as the configuration closest to the object takes precedence, but this is a really weird and not very expected behavior that is left intact "in-case you really need it".
The overall recommendation here is to "pick a horse" and either use a Nexus file OR use explicit object level definitions for overrides.
Development
Consult Spicerack's development instructions for more info.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/RubyAfterAll/spicerack.
License
The gem is available as open source under the terms of the MIT License.