LedgerSync::Domains
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.
Installation
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
Usage
LedgerSync comes with 3 components.
- Registration
- Resource
- Serializers
- Operations
Register your engines
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 your Main App
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
endRegister your Engine
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
endResource/Model
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
endAlternatively 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
endIn 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.
Serializers
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
endIn 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
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
endSuccessful 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.
Internal operations
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
endWhen 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.
Cross-domain relationships
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.
Use of cross-domain relationships is bad
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.
Use of cross-domain relationships is fine
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.
Development
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.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/ledger_sync-domains.
License
The gem is available as open source under the terms of the MIT License.