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.
Files changed (174) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/CHANGELOG.md +25 -0
  4. data/LICENSE +21 -0
  5. data/README.md +1219 -0
  6. data/Rakefile +12 -0
  7. data/examples/blog_app/.dockerignore +51 -0
  8. data/examples/blog_app/.github/dependabot.yml +12 -0
  9. data/examples/blog_app/.github/workflows/ci.yml +67 -0
  10. data/examples/blog_app/.gitignore +30 -0
  11. data/examples/blog_app/.kamal/hooks/docker-setup.sample +3 -0
  12. data/examples/blog_app/.kamal/hooks/post-app-boot.sample +3 -0
  13. data/examples/blog_app/.kamal/hooks/post-deploy.sample +14 -0
  14. data/examples/blog_app/.kamal/hooks/post-proxy-reboot.sample +3 -0
  15. data/examples/blog_app/.kamal/hooks/pre-app-boot.sample +3 -0
  16. data/examples/blog_app/.kamal/hooks/pre-build.sample +51 -0
  17. data/examples/blog_app/.kamal/hooks/pre-connect.sample +47 -0
  18. data/examples/blog_app/.kamal/hooks/pre-deploy.sample +122 -0
  19. data/examples/blog_app/.kamal/hooks/pre-proxy-reboot.sample +3 -0
  20. data/examples/blog_app/.kamal/secrets +20 -0
  21. data/examples/blog_app/.rubocop.yml +8 -0
  22. data/examples/blog_app/.ruby-version +1 -0
  23. data/examples/blog_app/Dockerfile +76 -0
  24. data/examples/blog_app/Gemfile +63 -0
  25. data/examples/blog_app/Gemfile.lock +408 -0
  26. data/examples/blog_app/README.md +24 -0
  27. data/examples/blog_app/README_EXAMPLE.md +328 -0
  28. data/examples/blog_app/Rakefile +6 -0
  29. data/examples/blog_app/app/assets/images/.keep +0 -0
  30. data/examples/blog_app/app/assets/stylesheets/application.css +10 -0
  31. data/examples/blog_app/app/controllers/api/base_controller.rb +61 -0
  32. data/examples/blog_app/app/controllers/api/v1/posts_controller.rb +158 -0
  33. data/examples/blog_app/app/controllers/api/v1/users_controller.rb +98 -0
  34. data/examples/blog_app/app/controllers/application_controller.rb +7 -0
  35. data/examples/blog_app/app/controllers/concerns/.keep +0 -0
  36. data/examples/blog_app/app/domains/.keep +0 -0
  37. data/examples/blog_app/app/domains/exceptions.rb +19 -0
  38. data/examples/blog_app/app/domains/post_management/events/post_created_event.rb +15 -0
  39. data/examples/blog_app/app/domains/post_management/events/post_deleted_event.rb +13 -0
  40. data/examples/blog_app/app/domains/post_management/events/post_updated_event.rb +13 -0
  41. data/examples/blog_app/app/domains/post_management/handlers/post_notification_handler.rb +33 -0
  42. data/examples/blog_app/app/domains/post_management/models/post.rb +21 -0
  43. data/examples/blog_app/app/domains/post_management/policies/post_policy.rb +49 -0
  44. data/examples/blog_app/app/domains/post_management/post_service.rb +93 -0
  45. data/examples/blog_app/app/domains/post_management/setup.rb +25 -0
  46. data/examples/blog_app/app/domains/user_management/events/user_created_event.rb +15 -0
  47. data/examples/blog_app/app/domains/user_management/events/user_deleted_event.rb +13 -0
  48. data/examples/blog_app/app/domains/user_management/events/user_updated_event.rb +13 -0
  49. data/examples/blog_app/app/domains/user_management/handlers/user_welcome_handler.rb +21 -0
  50. data/examples/blog_app/app/domains/user_management/models/user.rb +11 -0
  51. data/examples/blog_app/app/domains/user_management/policies/user_policy.rb +49 -0
  52. data/examples/blog_app/app/domains/user_management/setup.rb +25 -0
  53. data/examples/blog_app/app/domains/user_management/user_service.rb +93 -0
  54. data/examples/blog_app/app/events/.keep +0 -0
  55. data/examples/blog_app/app/events/application_event.rb +18 -0
  56. data/examples/blog_app/app/handlers/.keep +0 -0
  57. data/examples/blog_app/app/helpers/application_helper.rb +2 -0
  58. data/examples/blog_app/app/javascript/application.js +3 -0
  59. data/examples/blog_app/app/javascript/controllers/application.js +9 -0
  60. data/examples/blog_app/app/javascript/controllers/hello_controller.js +7 -0
  61. data/examples/blog_app/app/javascript/controllers/index.js +4 -0
  62. data/examples/blog_app/app/jobs/application_job.rb +7 -0
  63. data/examples/blog_app/app/mailers/application_mailer.rb +4 -0
  64. data/examples/blog_app/app/models/application_record.rb +3 -0
  65. data/examples/blog_app/app/models/concerns/.keep +0 -0
  66. data/examples/blog_app/app/models/organization.rb +6 -0
  67. data/examples/blog_app/app/policies/.keep +0 -0
  68. data/examples/blog_app/app/policies/application_policy.rb +23 -0
  69. data/examples/blog_app/app/services/.keep +0 -0
  70. data/examples/blog_app/app/services/application_service.rb +24 -0
  71. data/examples/blog_app/app/views/layouts/application.html.erb +29 -0
  72. data/examples/blog_app/app/views/layouts/mailer.html.erb +13 -0
  73. data/examples/blog_app/app/views/layouts/mailer.text.erb +1 -0
  74. data/examples/blog_app/app/views/pwa/manifest.json.erb +22 -0
  75. data/examples/blog_app/app/views/pwa/service-worker.js +26 -0
  76. data/examples/blog_app/bin/brakeman +7 -0
  77. data/examples/blog_app/bin/bundler-audit +6 -0
  78. data/examples/blog_app/bin/ci +6 -0
  79. data/examples/blog_app/bin/dev +2 -0
  80. data/examples/blog_app/bin/docker-entrypoint +8 -0
  81. data/examples/blog_app/bin/importmap +4 -0
  82. data/examples/blog_app/bin/jobs +6 -0
  83. data/examples/blog_app/bin/kamal +27 -0
  84. data/examples/blog_app/bin/rails +4 -0
  85. data/examples/blog_app/bin/rake +4 -0
  86. data/examples/blog_app/bin/rubocop +8 -0
  87. data/examples/blog_app/bin/setup +35 -0
  88. data/examples/blog_app/bin/thrust +5 -0
  89. data/examples/blog_app/config/application.rb +52 -0
  90. data/examples/blog_app/config/boot.rb +4 -0
  91. data/examples/blog_app/config/bundler-audit.yml +5 -0
  92. data/examples/blog_app/config/cable.yml +17 -0
  93. data/examples/blog_app/config/cache.yml +16 -0
  94. data/examples/blog_app/config/ci.rb +19 -0
  95. data/examples/blog_app/config/credentials.yml.enc +1 -0
  96. data/examples/blog_app/config/database.yml +41 -0
  97. data/examples/blog_app/config/deploy.yml +120 -0
  98. data/examples/blog_app/config/environment.rb +5 -0
  99. data/examples/blog_app/config/environments/development.rb +78 -0
  100. data/examples/blog_app/config/environments/production.rb +90 -0
  101. data/examples/blog_app/config/environments/test.rb +53 -0
  102. data/examples/blog_app/config/importmap.rb +7 -0
  103. data/examples/blog_app/config/initializers/active_domain.rb +37 -0
  104. data/examples/blog_app/config/initializers/assets.rb +7 -0
  105. data/examples/blog_app/config/initializers/content_security_policy.rb +29 -0
  106. data/examples/blog_app/config/initializers/filter_parameter_logging.rb +8 -0
  107. data/examples/blog_app/config/initializers/inflections.rb +16 -0
  108. data/examples/blog_app/config/locales/en.yml +31 -0
  109. data/examples/blog_app/config/master.key +1 -0
  110. data/examples/blog_app/config/puma.rb +42 -0
  111. data/examples/blog_app/config/queue.yml +18 -0
  112. data/examples/blog_app/config/recurring.yml +15 -0
  113. data/examples/blog_app/config/routes.rb +27 -0
  114. data/examples/blog_app/config/storage.yml +27 -0
  115. data/examples/blog_app/config.ru +6 -0
  116. data/examples/blog_app/db/cable_schema.rb +11 -0
  117. data/examples/blog_app/db/cache_schema.rb +12 -0
  118. data/examples/blog_app/db/migrate/20251230112502_create_organizations.rb +9 -0
  119. data/examples/blog_app/db/migrate/20251230112503_create_users.rb +12 -0
  120. data/examples/blog_app/db/migrate/20251230112504_create_posts.rb +14 -0
  121. data/examples/blog_app/db/queue_schema.rb +129 -0
  122. data/examples/blog_app/db/schema.rb +46 -0
  123. data/examples/blog_app/db/seeds.rb +9 -0
  124. data/examples/blog_app/lib/api_demo.rb +175 -0
  125. data/examples/blog_app/lib/demo.rb +150 -0
  126. data/examples/blog_app/lib/tasks/.keep +0 -0
  127. data/examples/blog_app/log/.keep +0 -0
  128. data/examples/blog_app/public/400.html +135 -0
  129. data/examples/blog_app/public/404.html +135 -0
  130. data/examples/blog_app/public/406-unsupported-browser.html +135 -0
  131. data/examples/blog_app/public/422.html +135 -0
  132. data/examples/blog_app/public/500.html +135 -0
  133. data/examples/blog_app/public/icon.png +0 -0
  134. data/examples/blog_app/public/icon.svg +3 -0
  135. data/examples/blog_app/public/robots.txt +1 -0
  136. data/examples/blog_app/script/.keep +0 -0
  137. data/examples/blog_app/storage/.keep +0 -0
  138. data/examples/blog_app/tmp/.keep +0 -0
  139. data/examples/blog_app/vendor/.keep +0 -0
  140. data/examples/blog_app/vendor/javascript/.keep +0 -0
  141. data/lib/generators/active_domain/domain/domain_generator.rb +116 -0
  142. data/lib/generators/active_domain/domain/templates/events/created_event.rb.tt +15 -0
  143. data/lib/generators/active_domain/domain/templates/events/deleted_event.rb.tt +13 -0
  144. data/lib/generators/active_domain/domain/templates/events/updated_event.rb.tt +13 -0
  145. data/lib/generators/active_domain/domain/templates/policy.rb.tt +49 -0
  146. data/lib/generators/active_domain/domain/templates/service.rb.tt +93 -0
  147. data/lib/generators/active_domain/domain/templates/setup.rb.tt +27 -0
  148. data/lib/generators/active_domain/install/install_generator.rb +58 -0
  149. data/lib/generators/active_domain/install/templates/README +28 -0
  150. data/lib/generators/active_domain/install/templates/application_event.rb +18 -0
  151. data/lib/generators/active_domain/install/templates/application_policy.rb +23 -0
  152. data/lib/generators/active_domain/install/templates/application_service.rb +24 -0
  153. data/lib/generators/active_domain/install/templates/initializer.rb +37 -0
  154. data/lib/smart_domain/configuration.rb +97 -0
  155. data/lib/smart_domain/domain/exceptions.rb +164 -0
  156. data/lib/smart_domain/domain/policy.rb +215 -0
  157. data/lib/smart_domain/domain/service.rb +230 -0
  158. data/lib/smart_domain/event/adapters/memory.rb +110 -0
  159. data/lib/smart_domain/event/base.rb +176 -0
  160. data/lib/smart_domain/event/handler.rb +98 -0
  161. data/lib/smart_domain/event/mixins.rb +156 -0
  162. data/lib/smart_domain/event/registration.rb +136 -0
  163. data/lib/smart_domain/generators/domain_generator.rb +4 -0
  164. data/lib/smart_domain/generators/install_generator.rb +4 -0
  165. data/lib/smart_domain/handlers/audit_handler.rb +216 -0
  166. data/lib/smart_domain/handlers/metrics_handler.rb +104 -0
  167. data/lib/smart_domain/integration/active_record.rb +169 -0
  168. data/lib/smart_domain/integration/multi_tenancy.rb +115 -0
  169. data/lib/smart_domain/railtie.rb +62 -0
  170. data/lib/smart_domain/tasks/domains.rake +43 -0
  171. data/lib/smart_domain/version.rb +5 -0
  172. data/lib/smart_domain.rb +77 -0
  173. data/smart_domain.gemspec +53 -0
  174. 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
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Load the domain generator
4
+ require_relative "../../generators/smart_domain/domain/domain_generator"
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Load the install generator
4
+ require_relative "../../generators/smart_domain/install/install_generator"