title: Punch Introduction keywords:
- ruby
- source-code-generator
- interactor
- service
- entity
- plugin
- business-logic-layer
- the-clean-architecture
- domain-driven-design ...
Playing with The Clean Architecture in 2021 I found it just an amazing tool, but a bit tiresome also because of the necessity to constantly create and require a bunch of template classes like entities and services. That was the reason for designing Punch. I wanted just a few things
- Design my code base by The Clean Architecture principles, so it will be easy to write, read, test, and evolve.
- Start as small as possible with just PORO by adopting services, entities, and plugins, postponing particular technologies as much as possible (CLI, Web, Message Brokers, Storage, etc.)
- Punch (generate) services, entities, and plugins to boost productivity and reduce mistakes by generating them instead of writing manually.
Punch provides a few basic stuff:
- Four basic concepts - Service, Entity, Plugin, Sentry
- source code templates for the concepts
- CLI for punching the concepts
- DSL to express and "punch" the domain of the concepts
The crucial part and the face of any domain is a set of actors (users) and services (use cases, interactors) provided by the domain to the actors. This part "screams" about the domain intent.
Punch provides you with its own basic service that is used across the Punch itself for punching concepts.
Regular domain service operates with business entities and other environment-provided stuff using plugins. The service is also considered the domain boundary and must guard the domain against wrong incoming data. My basic domain service in 99% cases looks like this:
require_relative '../basics'
require_relative '../plugins'
require_relative '../sentries'
require_relative '../entities'
require 'forwardable'
# Basic Domain Service
class Service < Punch::Service
include Entities
extend Forwardable
def_delegator :PluginHolder, :object, :plugin
end
The basic service will be utilized by a regular service like this:
require_relative 'service'
class CreateOrder < Service
def initialize(user_id:)
@user_id = MustbeUUID.(user_id, :user_id)
end
# @return [Order]
def call
user = store.get(User, id: @user_id)
fail Failure, 'unknown user' unless user
order = Order.new(user_id: @user_id)
store.put(order)
end
end
Depending on domain purpose, it could operate with value objects instead of entities. It is exactly the Punch case that does not operate with any business data but uses value objects placed under Punch::Models namespace.
The good name for a domain service will include actor and action, consider names like UserCreateOrder, ManagerRejectOrder, SystemNotifyParties, etc.
The Entity represents some data record and the simplest (even "native") way to express such records is to use the regular Ruby 3.2 Data class
class Order < Data.define(:id, :created_at, :created_by)
def initialize(id: SecureRandom.uuid, created_at: Time.now, created_by:)
super
end
end
And it is the point where the first reflection appears - should the entity guard constructor arguments or it is the responsibility of the appropriate service that creates the entity.
In regular user applications, usual flow will be the following:
- The actor requests the service, passing required parameters
- The service creates the entity and returns it to the actor.
In such cases, the native way will be the service that guards parameters
class CreateOrder < Service
def initialize(user_id:)
@user_id = MustbeUUID.(user_id, :user_id)
end
def call
order = Order.new(created_by: @user_id)
store.put(order)
end
end
When one deals mainly with complex value objects (models, data without identity), maybe it will be more suitable to guard model parameters in the constructor of the model
class Param < Data.define(:name, :type, :desc)
def initialize(name:, type:, desc: '')
MustbeStrOrSym.(name, :name)
MustbeStrOrSym.(type, :type)
MustbeStrOrSym.(desc, :desc)
super
end
end
MustbeParamCollection = Sentry.new(:params, 'must be Array<Param>'
) {|v| v.is_a?(Array) && v.all?{|e| e.is_a?(Param)} }
class Model < Data.define(:name, :params, :desc)
def initialize(name:, params:, desc: '')
MustbeStrOrSym.(name, :name)
MustbeStrOrSym.(desc, :desc)
MustbeParamCollection.(params)
super
end
end
A plugin represents an interface to the world outside the domain. The real world consists of database servers, file storages, message brokers, etc. But the interface should be tied to the domain, not to the tech beside the real world.
Assuming that domain entities are stored inside some data store, one could sketch the simples store interface like follows:
class Store
extend Plugin
# @param klass [Class]
# @param criteria [Hash]
# @return [Object] first :klass object matched to :criteria or nil when not found
def get(klass, **criteria)
fail "#{self.class}#get must be overridden"
end
# @param obj [Object]
# @return [Object] put object
def put(obj)
fail "#{self.class}#get must be overridden"
end
end
To bring plugins to the domain services Punch provides "plugins.rb"
require_relative 'plugins/store'
StoreHolder = Store.plugin
At the domain development stage there is no necessity to have and even choose real tech behind the interface - the good practice to mock and stub the real world for testing purpose (minitest examples of using mock provided inside generated tests).
The sentry is a little helper to guard the domain against wrong input data. Domain services and entities usually share a bunch of data primitives like UUID, email, string 256, positive integer no more than 100, etc.
The example of utilizing sentries placed above in Entity section.
MustbeParamCollection = Sentry.new(:params, 'must be Array<Param>'
) {|v| v.is_a?(Array) && v.all?{|e| e.is_a?(Param)} }
When one have working clean domain of services, entities, and plugins the application design becomes just tech adoption and configuration stuff. Basically, one need
- Provide the user interface layer (CLI, Web, API, Message Broker, etc.)
- Implement plugin interfaces for chosen technologies (database, external API, etc.)
- Configure the app by assembling the app interface, services, and plugin implementation
Some first preliminary numbers shows that my apps is something about 700 SLOC Ruby with blanks and comments. And important thing is it won't grow further when domain grows.
At this time I've "punched" a few simple domains and saw some statistics at the beginning and at the end of the project (the punch stat
command was provided especially for the need)
[The very first demo]{.underline} repo punch_users (just domain without app) shows:
- 85% of sources were "punched" and 15% were created manually;
- 50% of Ruby LOC were "punched" and the other 50% were created manually.
Location Total "Punched" SLOC Blank Comments Net Ruby LOC
lib 23 (17) 13 (13) 657 (329) 102 (53) 175 (93) 380 (183) test 17 (17) 15 (16) 363 (335) 46 (38) 45 (150) 272 (147) lib + test 40 (34) 28 (29) 1020 (664) 148 (91) 220 (243) 652 (330)
[The next demo]{.underline} repo with a few app interfaces and plugins
[One of the real project]{.underline} statistics .. unfortunately have no stat just after punching domain
"scc app" shows how small the app really is (3200 for domain and 700 for app!)
Language Files Lines Blanks Comments Code Complexity
───────────────────────────────────────────────────────────────────────────────
Ruby 9 710 82 124 504 35
punch status a bit polluted test by tech plugins test
Looking through 'lib', 'test' directories..
- 85 sources, 52 "punched" (4 remain "punched")
- 3178 SLOC, 458 blank and 617 comment lines
'lib' summary:
- 39 sources, 25 "punched" (0 remain "punched")
- 1349 SLOC, with 198 blank lines and 413 comments
'test' summary:
- 46 sources, 27 "punched" (4 remain "punched")
- 1829 SLOC, with 260 blank lines and 204 comments
remain "punched":
- test/ba.../entities/test_device.rb
- test/ba.../entities/test_target.rb
- test/ba.../services/test_user_query_drivers.rb
- test/ba.../services/test_user_query_targets.rb