Achieving Domain Driven Design (DDD) inside of a rails application is not a simple task. Rails engines by themself provide minimal value for code isolation. There are several gems that allows you to handle cross-engine communication through service/operation/inflection classes. Unfortunately these only touches execution, but they won't help you with passing around serialized objects.
LedgerSync has been developed to handle cross-service (API) communication in elegant way. Same aproach can be used for cross-domain communication.
Operations are great to ensure there is single point to perform specific action. If the return object is regular ActiveRecord Model object, there is nothing that stops developer from accessing cross-domain relationship, updating it or calling another action on it. You can have rubocop scanning through the code and screaming every time it finds something fishy, or you can just stop passing ActiveRecord objects around. And that is where LedgerSync::Serializer
comes handy. It gives you simple way to define how your object should look towards specific domain. Instead of passing ruby hash(es), you work with OpenStruct
objects that supports relationships.
Use LedgerSync::Operation
to trigger actions from other domains and LedgerSync::Serializer
to pass around serialized objects instead of ActiveRecord Models. ActiveRecord Models are compatible with LedgerSync Resources and can be serialized to OpenStruct objects automatically.
Add this line to your application's Gemfile:
gem 'ledger_sync-domains'
And then execute:
$ bundle install
Or install it yourself as:
$ gem install ledger_sync-domains
LedgerSync comes with 3 components.
- Registration
- Resource
- Serializers
- Operations
LedgerSync::Domains
requires little configuration so it can properly pick up domains and their namespace. While LedgerSync::Domains
is not required to be used with Rails, it is the most expected usecase. THe problem with Rails (and possibly other codebases) is that the MainApp
does not have a module namespace and referencing it from other domain operations is not as simple. There are two methods to help you register.
register_main_domain
creates a domain with name :main
without any base_module
. This way you can reference MainApp
as domain: :main
and serializers/operations will be picked up from there.
# config/application.rb
module DropBot
class Application < Rails::Application
LedgerSync::Domains.register_main_domain
# ...
end
end
register_domain
creates a domain with your own name
and your own base_module
. Lets say you place your engines under engines
folder and you just created billing
engine. To register it as a domain, update your engine.rb
file. After that use it in your operations as domain: :engine
. You can pass in string or symbol.
# engines/billing/lib/billing/engine.rb
module Billing
class Engine < ::Rails::Engine
isolate_namespace Billing
LedgerSync::Domains.register_domain(:billing, base_module: Billing)
end
end
While LedgerSync::Resources
represent object from external service, in case of domains we replace this with build in ActiveRecord Models. If you wish to use LedgerSync::Domains
outside of a rails app (there is nothing that stops you doing that), just use LedgerSync::Resource to prepare your data for Serializers.
Use of ActiveRecord Models allows us to easily work with current rails data structure. Define your models as you would do in any rails application.
class Customer < ActiveRecord::Base
has_many :addresses
belongs_to :user, class_name: 'Auth::User'
end
class Address < ActiveRecord::Base
belongs_to :customer
end
module Auth
class User < ActiveRecord::Base
has_many :customers
end
end
Alternatively you can use LedgerSync::Resource class to wrap your data into Model-like objects with relationships.
class Customer < LedgerSync::Resource
attribute :name, type: LedgerSync::Type::String
references_many :addresses, to: Address
references_one :user, to: Auth::User
end
class Address < LedgerSync::Resource
attribute :address, type: LedgerSync::Type::String
attribute :city, type: LedgerSync::Type::String
attribute :country, type: LedgerSync::Type::String
references_one :customer, to: Customer
end
module Auth
class User < LedgerSync::Resource
attribute :email, type: LedgerSync::Type::String
references_many :customers, to: Customer
end
end
In above example(s) you can see two models Customer
and Address
that are part of Main
domain, and User
model being part of Auth
domain. For the sake of examples, we will have also Client
domain that consumes both resources from Main
as well as Auth
domain.
Next step is to serialize records from these Models/Resources. One of the concepts of DDD is to be able to present your object differently to each domain. For example User
can exposes different set of attributes towards Client
domain as well as Main
domain. LedgerSync::Serializer
allows you to define serializer for each domain and specify which attributes will be exposed towards each domain.
module Auth
module Users
class AuthSerializer < LedgerSync::Domains::Serializer
attribute :email
end
class ClientSerializer < LedgerSync::Domains::Serializer
attribute :email
end
end
end
module Addresses
class ClientSerializer < LedgerSync::Domains::Serializer
attribute :address
attribute :city
attribute :state
end
end
module Customers
class AuthSerializer < LedgerSync::Domains::Serializer
attribute :id, resource_attribute: :ledger_id
attribute :name
references_one :user, resource_attribute: :user,
serializer: Auth::Users::AuthSerializer
end
class ClientSerializer < LedgerSync::Domains::Serializer
attribute :id, resource_attribute: :ledger_id
attribute :name
references_many :addresses, resource_attribute: :addresses,
serializer: Addresses::ClientSerializer
end
end
In above example we represent Customer
with two serializers aimed towards two domains. Customer::AuthSerializer
exposes customer and user
relationship, while Customer::ClientSerializer
exposes customer and addresses
relationship.
LedgerSync::Domains::Serializer
serializes into OpenStruct
object. Original resource is part of it and used to lazily load and serialize relationships. This way relationships are queried and serialized only once required. Here is quick example below.
Here is quick example how that looks in above example.
# load all above classes - watch for dependencies
irb(main):001:0> user = Auth::User.new(email: 'test@ledger_sync.dev')
=> #<Auth::User:0x00005624204f1da8 @resource_attributes=#<LedgerSync::ResourceAttributeSet:0x00005624204f1790 @attributes={:external_id=>#<L...
irb(main):002:0> customer = Customer.new(ledger_id: '123', name: 'LedgerSync', user: user)
=> #<Customer:0x0000562420792300 @resource_attributes=#<LedgerSync::ResourceAttributeSet:0x0000562420791dd8 @attributes={:external_id=>#<Led...
irb(main):003:0> auth_customer = Customers::AuthSerializer.new.serialize(resource: customer)
=> #<Customers::AuthStruct id="123", name="LedgerSync">
irb(main):004:0> auth_customer.user
=> #<Auth::Users::AuthStruct email="test@ledger_sync.dev">
irb(main):005:0> client_customer = Customers::ClientSerializer.new.serialize(resource: customer)
=> #<Customers::ClientStruct id="123", name="LedgerSync">
irb(main):006:0> client_customer.user
Traceback (most recent call last):
3: from ./bin/console:15:in `<main>'
2: from (irb):06:in `<main>'
1: from /root/.asdf/installs/ruby/3.0.0/lib/ruby/3.0.0/delegate.rb:91:in `method_missing'
NoMethodError (undefined method `user' for #<OpenStruct id="123", name="LedgerSync">)
irb(main):007:0> client_customer.addresses
=> []
Above you can see that client_customer
can't access user
relationship, while auth_customer
can.
Operations allows you to expose actions towards other domains. Validate input based on specified contract and return result object that you can use to retrieve data or error details. Here is sample operation to fetch object by specific ID and one to deactivate an user.
module Auth
module Users
class FindOperation < LedgerSync::Domains::Operation::Find
# Ha, that was easy!
end
end
end
module Auth
module Users
class DeactivateOperation
include LedgerSync::Domains::Operation::Mixin
class Contract < LedgerSync::Ledgers::Contract
params do
required(:id).value(:integer)
end
end
private
def operate
return failure('Not found') if resource.nil?
if resource.update(active: false)
success(resource)
else
failure('Unable to deactivate')
end
end
def resource
@resource ||= User.find_by(id: params[:id])
end
def failure(message)
super(
LedgerSync::Error::OperationError.new(
operation: self,
message: message
)
)
end
end
end
end
Successful result of an operation should be serialized resource(s). Operations automatically perform success result serialization for you. Any ActiveRecord::Base
or LedgerSync::Resource
object will be automatically serialized through matching serializer for the target domain. Any other value will remain untouched. Hashes and arrays are deep-serialized, so you can return hash or array including multiple ActiveRecord objects. Operation by itself doesn't know what domain triggered it, and therefore it requires target domain to be passed either as a module, string or symbol. Here is how that looks for the example above.
irb(main):001:0> op = Auth::Users::FindOperation.new(id: 1, limit: {}, domain: Client)
=> #<Auth::Users::FindOperation:0x0000555937876cf8 @serializer=#<Auth::Users::ClientSerializer:0x0000555937876dc0>, @deserializer=nil...
irb(main):002:0> op.perform
=> #<LedgerSync::Domains::Operation::OperationResult::Success:0x00005559372f9d70 @meta=nil, @value=#<OpenStruct email="test@ledger_sync.dev">>
irb(main):003:0> op.result.value
=> #<Auth::Users::ClientStruct email="test@ledger_sync.dev">
And thats it. Now you can use LedgerSync
to define operations and have their results serialized against specific domain you are requesting it from.
Sometimes you want to create operation, that is not accessible from the rest of the app. The nature of ruby allows all defined classes be accessible from everywhere. To prevent execution of an operation from different domain, use internal
flag when defining class.
module Auth
module Users
class FindOperation < LedgerSync::Domains::Operation::Find
internal
end
end
end
When performing an operation there are series of guards. First one validates if operation is allowed to be executed. That means either it is not flagged as internal operation, or target domain is same domain as module operation is defined in. If operation is not allowed to be executed, failure is returned with LedgerSync::Domains::OperationError
error.
One important note about relationships. Splitting your app into multiple engines is eventually gonna lead to your ActiveRecord Models to have relationships that reference Models from other engines. There are two ways how to look at this issue.
In this case, you don't define them. Or if you do, you override reader method to raise exception. If user
references customer
, you don't access it through user.customer
, but you retrieve it through operation Engine::Customers::FindOperation.new(id: user.customer_id, domain: 'OtherEngine')
. This is a clean solution, but it will lead to N+1 queries.
If you try to get into the root of an issue, you will realize that resources reference each other is not really the source of it. The problem is that if you work with ActiveRecord objects, there is nothing stopping you from accessing related records and modifying them.
That cannot happen when accessing objects from other domains. In that case you retrieve serialized object through operation. Accessing relationships through serialized object will always return serialized relationship.
The problem is when working with records within current engine where fetching data from ActiveRecord is accepted practice. There is nothing that stops you from crossing domain boundary.
The obvious solution is to use Operations to work with Models within current engine as well. Serialized OpenStruct
objects are (or try to be) compatible with ActiveRecord objects. They require almost zero changes in your templates. So think of them as drop-in replacement.
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 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 the created tag, and push the .gem
file to rubygems.org.
Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/ledger_sync-domains.
The gem is available as open source under the terms of the MIT License.