smart_domain 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +25 -0
- data/LICENSE +21 -0
- data/README.md +1219 -0
- data/Rakefile +12 -0
- data/examples/blog_app/.dockerignore +51 -0
- data/examples/blog_app/.github/dependabot.yml +12 -0
- data/examples/blog_app/.github/workflows/ci.yml +67 -0
- data/examples/blog_app/.gitignore +30 -0
- data/examples/blog_app/.kamal/hooks/docker-setup.sample +3 -0
- data/examples/blog_app/.kamal/hooks/post-app-boot.sample +3 -0
- data/examples/blog_app/.kamal/hooks/post-deploy.sample +14 -0
- data/examples/blog_app/.kamal/hooks/post-proxy-reboot.sample +3 -0
- data/examples/blog_app/.kamal/hooks/pre-app-boot.sample +3 -0
- data/examples/blog_app/.kamal/hooks/pre-build.sample +51 -0
- data/examples/blog_app/.kamal/hooks/pre-connect.sample +47 -0
- data/examples/blog_app/.kamal/hooks/pre-deploy.sample +122 -0
- data/examples/blog_app/.kamal/hooks/pre-proxy-reboot.sample +3 -0
- data/examples/blog_app/.kamal/secrets +20 -0
- data/examples/blog_app/.rubocop.yml +8 -0
- data/examples/blog_app/.ruby-version +1 -0
- data/examples/blog_app/Dockerfile +76 -0
- data/examples/blog_app/Gemfile +63 -0
- data/examples/blog_app/Gemfile.lock +408 -0
- data/examples/blog_app/README.md +24 -0
- data/examples/blog_app/README_EXAMPLE.md +328 -0
- data/examples/blog_app/Rakefile +6 -0
- data/examples/blog_app/app/assets/images/.keep +0 -0
- data/examples/blog_app/app/assets/stylesheets/application.css +10 -0
- data/examples/blog_app/app/controllers/api/base_controller.rb +61 -0
- data/examples/blog_app/app/controllers/api/v1/posts_controller.rb +158 -0
- data/examples/blog_app/app/controllers/api/v1/users_controller.rb +98 -0
- data/examples/blog_app/app/controllers/application_controller.rb +7 -0
- data/examples/blog_app/app/controllers/concerns/.keep +0 -0
- data/examples/blog_app/app/domains/.keep +0 -0
- data/examples/blog_app/app/domains/exceptions.rb +19 -0
- data/examples/blog_app/app/domains/post_management/events/post_created_event.rb +15 -0
- data/examples/blog_app/app/domains/post_management/events/post_deleted_event.rb +13 -0
- data/examples/blog_app/app/domains/post_management/events/post_updated_event.rb +13 -0
- data/examples/blog_app/app/domains/post_management/handlers/post_notification_handler.rb +33 -0
- data/examples/blog_app/app/domains/post_management/models/post.rb +21 -0
- data/examples/blog_app/app/domains/post_management/policies/post_policy.rb +49 -0
- data/examples/blog_app/app/domains/post_management/post_service.rb +93 -0
- data/examples/blog_app/app/domains/post_management/setup.rb +25 -0
- data/examples/blog_app/app/domains/user_management/events/user_created_event.rb +15 -0
- data/examples/blog_app/app/domains/user_management/events/user_deleted_event.rb +13 -0
- data/examples/blog_app/app/domains/user_management/events/user_updated_event.rb +13 -0
- data/examples/blog_app/app/domains/user_management/handlers/user_welcome_handler.rb +21 -0
- data/examples/blog_app/app/domains/user_management/models/user.rb +11 -0
- data/examples/blog_app/app/domains/user_management/policies/user_policy.rb +49 -0
- data/examples/blog_app/app/domains/user_management/setup.rb +25 -0
- data/examples/blog_app/app/domains/user_management/user_service.rb +93 -0
- data/examples/blog_app/app/events/.keep +0 -0
- data/examples/blog_app/app/events/application_event.rb +18 -0
- data/examples/blog_app/app/handlers/.keep +0 -0
- data/examples/blog_app/app/helpers/application_helper.rb +2 -0
- data/examples/blog_app/app/javascript/application.js +3 -0
- data/examples/blog_app/app/javascript/controllers/application.js +9 -0
- data/examples/blog_app/app/javascript/controllers/hello_controller.js +7 -0
- data/examples/blog_app/app/javascript/controllers/index.js +4 -0
- data/examples/blog_app/app/jobs/application_job.rb +7 -0
- data/examples/blog_app/app/mailers/application_mailer.rb +4 -0
- data/examples/blog_app/app/models/application_record.rb +3 -0
- data/examples/blog_app/app/models/concerns/.keep +0 -0
- data/examples/blog_app/app/models/organization.rb +6 -0
- data/examples/blog_app/app/policies/.keep +0 -0
- data/examples/blog_app/app/policies/application_policy.rb +23 -0
- data/examples/blog_app/app/services/.keep +0 -0
- data/examples/blog_app/app/services/application_service.rb +24 -0
- data/examples/blog_app/app/views/layouts/application.html.erb +29 -0
- data/examples/blog_app/app/views/layouts/mailer.html.erb +13 -0
- data/examples/blog_app/app/views/layouts/mailer.text.erb +1 -0
- data/examples/blog_app/app/views/pwa/manifest.json.erb +22 -0
- data/examples/blog_app/app/views/pwa/service-worker.js +26 -0
- data/examples/blog_app/bin/brakeman +7 -0
- data/examples/blog_app/bin/bundler-audit +6 -0
- data/examples/blog_app/bin/ci +6 -0
- data/examples/blog_app/bin/dev +2 -0
- data/examples/blog_app/bin/docker-entrypoint +8 -0
- data/examples/blog_app/bin/importmap +4 -0
- data/examples/blog_app/bin/jobs +6 -0
- data/examples/blog_app/bin/kamal +27 -0
- data/examples/blog_app/bin/rails +4 -0
- data/examples/blog_app/bin/rake +4 -0
- data/examples/blog_app/bin/rubocop +8 -0
- data/examples/blog_app/bin/setup +35 -0
- data/examples/blog_app/bin/thrust +5 -0
- data/examples/blog_app/config/application.rb +52 -0
- data/examples/blog_app/config/boot.rb +4 -0
- data/examples/blog_app/config/bundler-audit.yml +5 -0
- data/examples/blog_app/config/cable.yml +17 -0
- data/examples/blog_app/config/cache.yml +16 -0
- data/examples/blog_app/config/ci.rb +19 -0
- data/examples/blog_app/config/credentials.yml.enc +1 -0
- data/examples/blog_app/config/database.yml +41 -0
- data/examples/blog_app/config/deploy.yml +120 -0
- data/examples/blog_app/config/environment.rb +5 -0
- data/examples/blog_app/config/environments/development.rb +78 -0
- data/examples/blog_app/config/environments/production.rb +90 -0
- data/examples/blog_app/config/environments/test.rb +53 -0
- data/examples/blog_app/config/importmap.rb +7 -0
- data/examples/blog_app/config/initializers/active_domain.rb +37 -0
- data/examples/blog_app/config/initializers/assets.rb +7 -0
- data/examples/blog_app/config/initializers/content_security_policy.rb +29 -0
- data/examples/blog_app/config/initializers/filter_parameter_logging.rb +8 -0
- data/examples/blog_app/config/initializers/inflections.rb +16 -0
- data/examples/blog_app/config/locales/en.yml +31 -0
- data/examples/blog_app/config/master.key +1 -0
- data/examples/blog_app/config/puma.rb +42 -0
- data/examples/blog_app/config/queue.yml +18 -0
- data/examples/blog_app/config/recurring.yml +15 -0
- data/examples/blog_app/config/routes.rb +27 -0
- data/examples/blog_app/config/storage.yml +27 -0
- data/examples/blog_app/config.ru +6 -0
- data/examples/blog_app/db/cable_schema.rb +11 -0
- data/examples/blog_app/db/cache_schema.rb +12 -0
- data/examples/blog_app/db/migrate/20251230112502_create_organizations.rb +9 -0
- data/examples/blog_app/db/migrate/20251230112503_create_users.rb +12 -0
- data/examples/blog_app/db/migrate/20251230112504_create_posts.rb +14 -0
- data/examples/blog_app/db/queue_schema.rb +129 -0
- data/examples/blog_app/db/schema.rb +46 -0
- data/examples/blog_app/db/seeds.rb +9 -0
- data/examples/blog_app/lib/api_demo.rb +175 -0
- data/examples/blog_app/lib/demo.rb +150 -0
- data/examples/blog_app/lib/tasks/.keep +0 -0
- data/examples/blog_app/log/.keep +0 -0
- data/examples/blog_app/public/400.html +135 -0
- data/examples/blog_app/public/404.html +135 -0
- data/examples/blog_app/public/406-unsupported-browser.html +135 -0
- data/examples/blog_app/public/422.html +135 -0
- data/examples/blog_app/public/500.html +135 -0
- data/examples/blog_app/public/icon.png +0 -0
- data/examples/blog_app/public/icon.svg +3 -0
- data/examples/blog_app/public/robots.txt +1 -0
- data/examples/blog_app/script/.keep +0 -0
- data/examples/blog_app/storage/.keep +0 -0
- data/examples/blog_app/tmp/.keep +0 -0
- data/examples/blog_app/vendor/.keep +0 -0
- data/examples/blog_app/vendor/javascript/.keep +0 -0
- data/lib/generators/active_domain/domain/domain_generator.rb +116 -0
- data/lib/generators/active_domain/domain/templates/events/created_event.rb.tt +15 -0
- data/lib/generators/active_domain/domain/templates/events/deleted_event.rb.tt +13 -0
- data/lib/generators/active_domain/domain/templates/events/updated_event.rb.tt +13 -0
- data/lib/generators/active_domain/domain/templates/policy.rb.tt +49 -0
- data/lib/generators/active_domain/domain/templates/service.rb.tt +93 -0
- data/lib/generators/active_domain/domain/templates/setup.rb.tt +27 -0
- data/lib/generators/active_domain/install/install_generator.rb +58 -0
- data/lib/generators/active_domain/install/templates/README +28 -0
- data/lib/generators/active_domain/install/templates/application_event.rb +18 -0
- data/lib/generators/active_domain/install/templates/application_policy.rb +23 -0
- data/lib/generators/active_domain/install/templates/application_service.rb +24 -0
- data/lib/generators/active_domain/install/templates/initializer.rb +37 -0
- data/lib/smart_domain/configuration.rb +97 -0
- data/lib/smart_domain/domain/exceptions.rb +164 -0
- data/lib/smart_domain/domain/policy.rb +215 -0
- data/lib/smart_domain/domain/service.rb +230 -0
- data/lib/smart_domain/event/adapters/memory.rb +110 -0
- data/lib/smart_domain/event/base.rb +176 -0
- data/lib/smart_domain/event/handler.rb +98 -0
- data/lib/smart_domain/event/mixins.rb +156 -0
- data/lib/smart_domain/event/registration.rb +136 -0
- data/lib/smart_domain/generators/domain_generator.rb +4 -0
- data/lib/smart_domain/generators/install_generator.rb +4 -0
- data/lib/smart_domain/handlers/audit_handler.rb +216 -0
- data/lib/smart_domain/handlers/metrics_handler.rb +104 -0
- data/lib/smart_domain/integration/active_record.rb +169 -0
- data/lib/smart_domain/integration/multi_tenancy.rb +115 -0
- data/lib/smart_domain/railtie.rb +62 -0
- data/lib/smart_domain/tasks/domains.rake +43 -0
- data/lib/smart_domain/version.rb +5 -0
- data/lib/smart_domain.rb +77 -0
- data/smart_domain.gemspec +53 -0
- metadata +391 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_model"
|
|
4
|
+
require "active_support/core_ext/object/blank"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "logger"
|
|
7
|
+
|
|
8
|
+
module SmartDomain
|
|
9
|
+
module Event
|
|
10
|
+
# Base class for all domain events in the system.
|
|
11
|
+
#
|
|
12
|
+
# Domain events represent significant business occurrences that other
|
|
13
|
+
# parts of the system need to know about, such as user registrations,
|
|
14
|
+
# order placements, or inventory changes.
|
|
15
|
+
#
|
|
16
|
+
# Events are immutable once created - all attributes are frozen.
|
|
17
|
+
#
|
|
18
|
+
# @example Define a custom event
|
|
19
|
+
# class UserCreatedEvent < SmartDomain::Event::Base
|
|
20
|
+
# attribute :user_id, :string
|
|
21
|
+
# attribute :email, :string
|
|
22
|
+
#
|
|
23
|
+
# validates :user_id, :email, presence: true
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# @example Create and publish an event
|
|
27
|
+
# event = UserCreatedEvent.new(
|
|
28
|
+
# event_type: 'user.created',
|
|
29
|
+
# aggregate_id: user.id,
|
|
30
|
+
# aggregate_type: 'User',
|
|
31
|
+
# organization_id: org.id,
|
|
32
|
+
# user_id: user.id,
|
|
33
|
+
# email: user.email
|
|
34
|
+
# )
|
|
35
|
+
#
|
|
36
|
+
# SmartDomain::Event.bus.publish(event)
|
|
37
|
+
class Base
|
|
38
|
+
include ActiveModel::Model
|
|
39
|
+
include ActiveModel::Attributes
|
|
40
|
+
include ActiveModel::Validations
|
|
41
|
+
|
|
42
|
+
# Core event fields
|
|
43
|
+
attribute :event_id, :string, default: -> { SecureRandom.uuid }
|
|
44
|
+
attribute :event_type, :string
|
|
45
|
+
attribute :aggregate_id, :string
|
|
46
|
+
attribute :aggregate_type, :string
|
|
47
|
+
attribute :organization_id, :string
|
|
48
|
+
attribute :occurred_at, :datetime, default: -> { Time.current }
|
|
49
|
+
attribute :version, :integer, default: 1
|
|
50
|
+
attribute :correlation_id, :string
|
|
51
|
+
attribute :causation_id, :string
|
|
52
|
+
attribute :metadata, default: -> { {} }
|
|
53
|
+
|
|
54
|
+
# Validate required fields
|
|
55
|
+
validates :event_type, :aggregate_id, :aggregate_type, :organization_id, presence: true
|
|
56
|
+
|
|
57
|
+
# Initialize event and freeze it (immutability)
|
|
58
|
+
def initialize(attributes = {})
|
|
59
|
+
super
|
|
60
|
+
freeze_event
|
|
61
|
+
validate!
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Convert event to hash
|
|
65
|
+
# @return [Hash] Event attributes as hash
|
|
66
|
+
def to_h
|
|
67
|
+
attributes.deep_symbolize_keys
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# String representation
|
|
71
|
+
# @return [String] Event representation
|
|
72
|
+
def to_s
|
|
73
|
+
"<#{self.class.name}(id=#{event_id}, type=#{event_type})>"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
alias inspect to_s
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
# Freeze the event to make it immutable
|
|
81
|
+
def freeze_event
|
|
82
|
+
@attributes.freeze
|
|
83
|
+
freeze
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Event bus for publishing and subscribing to domain events.
|
|
88
|
+
#
|
|
89
|
+
# The event bus follows the publish-subscribe pattern, allowing
|
|
90
|
+
# decoupled communication between different parts of the application.
|
|
91
|
+
#
|
|
92
|
+
# In production, this can be replaced with more robust message brokers
|
|
93
|
+
# like Redis, RabbitMQ, or AWS EventBridge via adapters.
|
|
94
|
+
#
|
|
95
|
+
# @example Subscribe to events
|
|
96
|
+
# bus = SmartDomain::Event::Bus.new
|
|
97
|
+
# handler = UserEmailHandler.new
|
|
98
|
+
# bus.subscribe('user.created', handler)
|
|
99
|
+
#
|
|
100
|
+
# @example Publish an event
|
|
101
|
+
# event = UserCreatedEvent.new(...)
|
|
102
|
+
# bus.publish(event)
|
|
103
|
+
class Bus
|
|
104
|
+
attr_reader :adapter, :logger
|
|
105
|
+
|
|
106
|
+
# Initialize event bus with optional adapter
|
|
107
|
+
# @param adapter [Object, Symbol] Event bus adapter or adapter name (default: Memory adapter)
|
|
108
|
+
def initialize(adapter: nil)
|
|
109
|
+
@adapter = resolve_adapter(adapter)
|
|
110
|
+
@logger = ActiveSupport::TaggedLogging.new(Logger.new($stdout))
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Subscribe a handler to a specific event type
|
|
114
|
+
# @param event_type [String] Event type to subscribe to (e.g., 'user.created')
|
|
115
|
+
# @param handler [Object] Handler object that responds to #handle(event)
|
|
116
|
+
def subscribe(event_type, handler)
|
|
117
|
+
@adapter.subscribe(event_type, handler)
|
|
118
|
+
@logger.info "[SmartDomain] Event handler subscribed: #{handler.class.name} -> #{event_type}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Publish an event to all registered handlers
|
|
122
|
+
# @param event [SmartDomain::Event::Base] Event to publish
|
|
123
|
+
# @raise [ArgumentError] If event is invalid
|
|
124
|
+
def publish(event)
|
|
125
|
+
validate_event!(event)
|
|
126
|
+
|
|
127
|
+
@logger.info "[SmartDomain] Publishing event: #{event.event_type} (#{event.event_id})"
|
|
128
|
+
@logger.debug "[SmartDomain] Event details: #{event.to_h}"
|
|
129
|
+
|
|
130
|
+
@adapter.publish(event)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
# Resolve adapter from symbol or object
|
|
136
|
+
# @param adapter [Object, Symbol, nil] Adapter instance, symbol, or nil
|
|
137
|
+
# @return [Object] Adapter instance
|
|
138
|
+
def resolve_adapter(adapter)
|
|
139
|
+
return Adapters::Memory.new if adapter.nil?
|
|
140
|
+
return adapter unless adapter.is_a?(Symbol)
|
|
141
|
+
|
|
142
|
+
case adapter
|
|
143
|
+
when :memory
|
|
144
|
+
Adapters::Memory.new
|
|
145
|
+
else
|
|
146
|
+
raise ArgumentError, "Unknown adapter: #{adapter}. Available adapters: :memory"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Validate event before publishing
|
|
151
|
+
# @param event [Object] Event to validate
|
|
152
|
+
# @raise [ArgumentError] If event is not valid
|
|
153
|
+
def validate_event!(event)
|
|
154
|
+
unless event.is_a?(Base)
|
|
155
|
+
raise ArgumentError, "Event must be a SmartDomain::Event::Base, got #{event.class.name}"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
return if event.valid?
|
|
159
|
+
|
|
160
|
+
raise ArgumentError, "Event validation failed: #{event.errors.full_messages.join(', ')}"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Global event bus singleton
|
|
165
|
+
# @return [SmartDomain::Event::Bus] Global event bus instance
|
|
166
|
+
def self.bus
|
|
167
|
+
@bus ||= Bus.new(adapter: SmartDomain.configuration&.event_bus_adapter)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Reset the global event bus (useful for testing)
|
|
171
|
+
# @api private
|
|
172
|
+
def self.reset_bus!
|
|
173
|
+
@bus = nil
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmartDomain
|
|
4
|
+
module Event
|
|
5
|
+
# Base class for event handlers.
|
|
6
|
+
#
|
|
7
|
+
# Event handlers process domain events and trigger side effects like
|
|
8
|
+
# sending emails, updating read models, logging, or triggering workflows.
|
|
9
|
+
#
|
|
10
|
+
# Handlers must implement the #handle method.
|
|
11
|
+
#
|
|
12
|
+
# @example Define a custom handler
|
|
13
|
+
# class UserEmailHandler < SmartDomain::Event::Handler
|
|
14
|
+
# def handle(event)
|
|
15
|
+
# case event.event_type
|
|
16
|
+
# when 'user.created'
|
|
17
|
+
# UserMailer.welcome_email(event.user_id).deliver_later
|
|
18
|
+
# when 'user.activated'
|
|
19
|
+
# UserMailer.account_activated(event.user_id).deliver_later
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# def can_handle?(event_type)
|
|
24
|
+
# ['user.created', 'user.activated'].include?(event_type)
|
|
25
|
+
# end
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# @example Subscribe the handler
|
|
29
|
+
# handler = UserEmailHandler.new
|
|
30
|
+
# SmartDomain::Event.bus.subscribe('user.created', handler)
|
|
31
|
+
# SmartDomain::Event.bus.subscribe('user.activated', handler)
|
|
32
|
+
class Handler
|
|
33
|
+
# Handle a domain event
|
|
34
|
+
#
|
|
35
|
+
# Subclasses must implement this method to process events.
|
|
36
|
+
#
|
|
37
|
+
# @param event [SmartDomain::Event::Base] Event to handle
|
|
38
|
+
# @raise [NotImplementedError] If not implemented by subclass
|
|
39
|
+
def handle(event)
|
|
40
|
+
raise NotImplementedError, "#{self.class.name} must implement #handle(event)"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Check if this handler can handle a specific event type
|
|
44
|
+
#
|
|
45
|
+
# Subclasses should implement this for filtering events.
|
|
46
|
+
# The default implementation returns true for all events.
|
|
47
|
+
#
|
|
48
|
+
# @param event_type [String] Event type to check
|
|
49
|
+
# @return [Boolean] True if handler can handle this event type
|
|
50
|
+
def can_handle?(event_type)
|
|
51
|
+
raise NotImplementedError, "#{self.class.name} must implement #can_handle?(event_type)"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Handle event asynchronously using ActiveJob
|
|
55
|
+
#
|
|
56
|
+
# This method queues the event handling in a background job.
|
|
57
|
+
# Requires ActiveJob to be configured in the Rails application.
|
|
58
|
+
#
|
|
59
|
+
# @param event [SmartDomain::Event::Base] Event to handle
|
|
60
|
+
# @raise [RuntimeError] If ActiveJob is not loaded
|
|
61
|
+
def handle_async(event)
|
|
62
|
+
unless defined?(ActiveJob)
|
|
63
|
+
raise "ActiveJob is required for async event handling. Please require 'active_job' in your application."
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
event.validate!
|
|
67
|
+
HandlerJob.perform_later(self.class.name, event.to_h)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# ActiveJob for asynchronous event handling
|
|
72
|
+
#
|
|
73
|
+
# This job handles events in the background, allowing the main
|
|
74
|
+
# request thread to continue without waiting for handlers to complete.
|
|
75
|
+
#
|
|
76
|
+
# @api private
|
|
77
|
+
if defined?(ActiveJob)
|
|
78
|
+
class HandlerJob < ActiveJob::Base
|
|
79
|
+
queue_as :default
|
|
80
|
+
|
|
81
|
+
# Perform asynchronous event handling
|
|
82
|
+
# @param handler_class_name [String] Handler class name
|
|
83
|
+
# @param event_data [Hash] Event data
|
|
84
|
+
def perform(handler_class_name, event_data)
|
|
85
|
+
handler = handler_class_name.constantize.new
|
|
86
|
+
event_class = event_data[:event_type].camelize.constantize
|
|
87
|
+
event = event_class.new(event_data)
|
|
88
|
+
|
|
89
|
+
handler.handle(event)
|
|
90
|
+
rescue StandardError => e
|
|
91
|
+
Rails.logger.error "[SmartDomain] Async handler failed: #{e.message}"
|
|
92
|
+
Rails.logger.error e.backtrace.join("\n")
|
|
93
|
+
raise
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module SmartDomain
|
|
6
|
+
module Event
|
|
7
|
+
# Reusable mixins for domain events to reduce boilerplate.
|
|
8
|
+
#
|
|
9
|
+
# These mixins provide common event fields following the pattern:
|
|
10
|
+
# - WHO performed the action (ActorMixin)
|
|
11
|
+
# - WHEN it occurred (AuditMixin)
|
|
12
|
+
# - WHAT changed (ChangeTrackingMixin)
|
|
13
|
+
# - WHERE from (SecurityContextMixin)
|
|
14
|
+
# - WHY (ReasonMixin)
|
|
15
|
+
#
|
|
16
|
+
# @example Using mixins in an event
|
|
17
|
+
# class UserUpdatedEvent < SmartDomain::Event::Base
|
|
18
|
+
# include SmartDomain::Event::ActorMixin
|
|
19
|
+
# include SmartDomain::Event::ChangeTrackingMixin
|
|
20
|
+
#
|
|
21
|
+
# attribute :user_id, :string
|
|
22
|
+
# end
|
|
23
|
+
|
|
24
|
+
# Mixin for tracking WHO performed the action
|
|
25
|
+
#
|
|
26
|
+
# Adds actor_id and actor_email fields to track which user
|
|
27
|
+
# triggered the event.
|
|
28
|
+
#
|
|
29
|
+
# @example
|
|
30
|
+
# class UserCreatedEvent < SmartDomain::Event::Base
|
|
31
|
+
# include SmartDomain::Event::ActorMixin
|
|
32
|
+
# end
|
|
33
|
+
#
|
|
34
|
+
# event = UserCreatedEvent.new(
|
|
35
|
+
# ...,
|
|
36
|
+
# actor_id: current_user.id,
|
|
37
|
+
# actor_email: current_user.email
|
|
38
|
+
# )
|
|
39
|
+
module ActorMixin
|
|
40
|
+
extend ActiveSupport::Concern
|
|
41
|
+
|
|
42
|
+
included do
|
|
43
|
+
attribute :actor_id, :string
|
|
44
|
+
attribute :actor_email, :string
|
|
45
|
+
|
|
46
|
+
validates :actor_id, presence: true
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Mixin for tracking WHEN the action occurred
|
|
51
|
+
#
|
|
52
|
+
# Adds occurred_at timestamp field. This is usually redundant with
|
|
53
|
+
# the base event class but can be included for explicit tracking.
|
|
54
|
+
#
|
|
55
|
+
# @example
|
|
56
|
+
# class PaymentProcessedEvent < SmartDomain::Event::Base
|
|
57
|
+
# include SmartDomain::Event::AuditMixin
|
|
58
|
+
# end
|
|
59
|
+
module AuditMixin
|
|
60
|
+
extend ActiveSupport::Concern
|
|
61
|
+
|
|
62
|
+
included do
|
|
63
|
+
attribute :occurred_at, :datetime, default: -> { Time.current }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Mixin for tracking WHAT changed in an update event
|
|
68
|
+
#
|
|
69
|
+
# Adds fields for tracking field-level changes:
|
|
70
|
+
# - changed_fields: Array of field names that changed
|
|
71
|
+
# - old_values: Hash of field => old value
|
|
72
|
+
# - new_values: Hash of field => new value
|
|
73
|
+
#
|
|
74
|
+
# @example
|
|
75
|
+
# class UserUpdatedEvent < SmartDomain::Event::Base
|
|
76
|
+
# include SmartDomain::Event::ChangeTrackingMixin
|
|
77
|
+
# end
|
|
78
|
+
#
|
|
79
|
+
# event = UserUpdatedEvent.new(
|
|
80
|
+
# ...,
|
|
81
|
+
# changed_fields: ['email', 'name'],
|
|
82
|
+
# old_values: { email: 'old@example.com', name: 'Old Name' },
|
|
83
|
+
# new_values: { email: 'new@example.com', name: 'New Name' }
|
|
84
|
+
# )
|
|
85
|
+
module ChangeTrackingMixin
|
|
86
|
+
extend ActiveSupport::Concern
|
|
87
|
+
|
|
88
|
+
included do
|
|
89
|
+
attribute :changed_fields, default: -> { [] }
|
|
90
|
+
attribute :old_values, default: -> { {} }
|
|
91
|
+
attribute :new_values, default: -> { {} }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Helper to extract changes from an ActiveRecord model
|
|
95
|
+
# @param record [ActiveRecord::Base] Record with changes
|
|
96
|
+
# @return [Hash] Hash with changed_fields, old_values, new_values
|
|
97
|
+
def self.changes_from(record)
|
|
98
|
+
return { changed_fields: [], old_values: {}, new_values: {} } unless record.respond_to?(:saved_changes)
|
|
99
|
+
|
|
100
|
+
changes = record.saved_changes
|
|
101
|
+
{
|
|
102
|
+
changed_fields: changes.keys,
|
|
103
|
+
old_values: changes.transform_values(&:first),
|
|
104
|
+
new_values: changes.transform_values(&:last)
|
|
105
|
+
}
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Mixin for tracking WHERE the action came from
|
|
110
|
+
#
|
|
111
|
+
# Adds security context fields:
|
|
112
|
+
# - ip_address: IP address of the request
|
|
113
|
+
# - user_agent: User agent string from the request
|
|
114
|
+
#
|
|
115
|
+
# @example
|
|
116
|
+
# class UserLoggedInEvent < SmartDomain::Event::Base
|
|
117
|
+
# include SmartDomain::Event::SecurityContextMixin
|
|
118
|
+
# end
|
|
119
|
+
#
|
|
120
|
+
# event = UserLoggedInEvent.new(
|
|
121
|
+
# ...,
|
|
122
|
+
# ip_address: request.remote_ip,
|
|
123
|
+
# user_agent: request.user_agent
|
|
124
|
+
# )
|
|
125
|
+
module SecurityContextMixin
|
|
126
|
+
extend ActiveSupport::Concern
|
|
127
|
+
|
|
128
|
+
included do
|
|
129
|
+
attribute :ip_address, :string
|
|
130
|
+
attribute :user_agent, :string
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Mixin for tracking WHY an action was performed
|
|
135
|
+
#
|
|
136
|
+
# Adds a reason field for documenting why an administrative
|
|
137
|
+
# action was taken.
|
|
138
|
+
#
|
|
139
|
+
# @example
|
|
140
|
+
# class UserSuspendedEvent < SmartDomain::Event::Base
|
|
141
|
+
# include SmartDomain::Event::ReasonMixin
|
|
142
|
+
# end
|
|
143
|
+
#
|
|
144
|
+
# event = UserSuspendedEvent.new(
|
|
145
|
+
# ...,
|
|
146
|
+
# reason: 'Violation of terms of service - spam activity detected'
|
|
147
|
+
# )
|
|
148
|
+
module ReasonMixin
|
|
149
|
+
extend ActiveSupport::Concern
|
|
150
|
+
|
|
151
|
+
included do
|
|
152
|
+
attribute :reason, :string
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../handlers/audit_handler"
|
|
4
|
+
require_relative "../handlers/metrics_handler"
|
|
5
|
+
|
|
6
|
+
module SmartDomain
|
|
7
|
+
module Event
|
|
8
|
+
# Event registration helpers for standardized handler setup.
|
|
9
|
+
#
|
|
10
|
+
# This module provides convenience methods to register common event handlers
|
|
11
|
+
# (audit, metrics) for domains, reducing boilerplate by approximately 70%.
|
|
12
|
+
#
|
|
13
|
+
# Instead of manually subscribing audit and metrics handlers to each event:
|
|
14
|
+
#
|
|
15
|
+
# audit = AuditHandler.new('user')
|
|
16
|
+
# metrics = MetricsHandler.new('user')
|
|
17
|
+
# bus.subscribe('user.created', audit)
|
|
18
|
+
# bus.subscribe('user.created', metrics)
|
|
19
|
+
# bus.subscribe('user.updated', audit)
|
|
20
|
+
# bus.subscribe('user.updated', metrics)
|
|
21
|
+
# # ... repeat for all events ...
|
|
22
|
+
#
|
|
23
|
+
# Use this one-liner:
|
|
24
|
+
#
|
|
25
|
+
# SmartDomain::Event::Registration.register_standard_handlers(
|
|
26
|
+
# domain: 'user',
|
|
27
|
+
# events: ['created', 'updated', 'deleted'],
|
|
28
|
+
# include_audit: true,
|
|
29
|
+
# include_metrics: true
|
|
30
|
+
# )
|
|
31
|
+
#
|
|
32
|
+
# Custom handlers (email, security, etc.) should still be registered explicitly.
|
|
33
|
+
module Registration
|
|
34
|
+
# Register standard audit and metrics handlers for a domain's events.
|
|
35
|
+
#
|
|
36
|
+
# This helper reduces boilerplate by automatically registering generic
|
|
37
|
+
# audit and metrics handlers for all events in a domain. Custom handlers
|
|
38
|
+
# (email, security, etc.) should still be registered explicitly.
|
|
39
|
+
#
|
|
40
|
+
# @param domain [String] Domain name (e.g., 'user', 'order', 'product')
|
|
41
|
+
# @param events [Array<String>] List of event actions (e.g., ['created', 'updated', 'deleted'])
|
|
42
|
+
# @param include_audit [Boolean] Whether to register audit handler (default: true)
|
|
43
|
+
# @param include_metrics [Boolean] Whether to register metrics handler (default: true)
|
|
44
|
+
# @return [Hash] Dictionary mapping handler type to list of registered event types
|
|
45
|
+
#
|
|
46
|
+
# @example Domain setup file
|
|
47
|
+
# # app/domains/user_management/setup.rb
|
|
48
|
+
# module UserManagement
|
|
49
|
+
# def self.setup!
|
|
50
|
+
# # Register standard handlers
|
|
51
|
+
# SmartDomain::Event::Registration.register_standard_handlers(
|
|
52
|
+
# domain: 'user',
|
|
53
|
+
# events: %w[created updated deleted activated suspended],
|
|
54
|
+
# include_audit: true,
|
|
55
|
+
# include_metrics: true
|
|
56
|
+
# )
|
|
57
|
+
#
|
|
58
|
+
# # Custom handlers still explicit
|
|
59
|
+
# email_handler = UserEmailHandler.new
|
|
60
|
+
# SmartDomain::Event.bus.subscribe('user.created', email_handler)
|
|
61
|
+
# SmartDomain::Event.bus.subscribe('user.activated', email_handler)
|
|
62
|
+
# end
|
|
63
|
+
# end
|
|
64
|
+
def self.register_standard_handlers(domain:, events:, include_audit: true, include_metrics: true)
|
|
65
|
+
registered = { audit: [], metrics: [] }
|
|
66
|
+
logger = SmartDomain.configuration.logger
|
|
67
|
+
|
|
68
|
+
if include_audit
|
|
69
|
+
audit_handler = SmartDomain::Handlers::AuditHandler.new(domain)
|
|
70
|
+
events.each do |action|
|
|
71
|
+
event_type = "#{domain}.#{action}"
|
|
72
|
+
SmartDomain::Event.bus.subscribe(event_type, audit_handler)
|
|
73
|
+
registered[:audit] << event_type
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
if include_metrics
|
|
78
|
+
metrics_handler = SmartDomain::Handlers::MetricsHandler.new(domain)
|
|
79
|
+
events.each do |action|
|
|
80
|
+
event_type = "#{domain}.#{action}"
|
|
81
|
+
SmartDomain::Event.bus.subscribe(event_type, metrics_handler)
|
|
82
|
+
registered[:metrics] << event_type
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Log what was registered
|
|
87
|
+
if include_audit || include_metrics
|
|
88
|
+
handlers_registered = []
|
|
89
|
+
handlers_registered << "audit" if include_audit
|
|
90
|
+
handlers_registered << "metrics" if include_metrics
|
|
91
|
+
|
|
92
|
+
logger.info "[SmartDomain::Registration] Standard handlers registered for #{domain} domain: " \
|
|
93
|
+
"#{handlers_registered.join(', ')} (#{events.size} events)"
|
|
94
|
+
logger.debug "[SmartDomain::Registration] Event types: #{events.map { |a| "#{domain}.#{a}" }.join(', ')}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
registered
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Register custom event handlers for a domain.
|
|
101
|
+
#
|
|
102
|
+
# This is a convenience method for registering multiple custom handlers
|
|
103
|
+
# to multiple events. Use this when you have custom handlers that need to
|
|
104
|
+
# listen to multiple events.
|
|
105
|
+
#
|
|
106
|
+
# @param domain [String] Domain name (for logging purposes)
|
|
107
|
+
# @param handlers [Hash] Hash mapping handler instances to list of event actions
|
|
108
|
+
#
|
|
109
|
+
# @example
|
|
110
|
+
# # app/domains/user_management/setup.rb
|
|
111
|
+
# email_handler = UserEmailHandler.new
|
|
112
|
+
# security_handler = UserSecurityHandler.new
|
|
113
|
+
#
|
|
114
|
+
# SmartDomain::Event::Registration.register_domain_handlers(
|
|
115
|
+
# domain: 'user',
|
|
116
|
+
# handlers: {
|
|
117
|
+
# email_handler => ['created', 'activated', 'suspended'],
|
|
118
|
+
# security_handler => ['suspended', 'deleted']
|
|
119
|
+
# }
|
|
120
|
+
# )
|
|
121
|
+
def self.register_domain_handlers(domain:, handlers:)
|
|
122
|
+
logger = SmartDomain.configuration.logger
|
|
123
|
+
|
|
124
|
+
handlers.each do |handler, event_actions|
|
|
125
|
+
event_actions.each do |action|
|
|
126
|
+
event_type = "#{domain}.#{action}"
|
|
127
|
+
SmartDomain::Event.bus.subscribe(event_type, handler)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
logger.info "[SmartDomain::Registration] Custom handlers registered for #{domain} domain " \
|
|
132
|
+
"(#{handlers.size} handler(s))"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|