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,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartDomain
4
+ module Domain
5
+ # Base class for domain policies (authorization).
6
+ #
7
+ # Policies encapsulate authorization logic for domain operations.
8
+ # They follow the Pundit-style pattern but are domain-centric.
9
+ #
10
+ # Key principles:
11
+ # - Policies are stateless (except for user and record context)
12
+ # - Policies answer "can this user perform this action on this record?"
13
+ # - Policies can be used in services, controllers, and views
14
+ # - Policies are domain-specific (UserPolicy, OrderPolicy, etc.)
15
+ #
16
+ # @example Define a policy
17
+ # class UserPolicy < SmartDomain::Domain::Policy
18
+ # def create?
19
+ # user.admin? || user.manager?
20
+ # end
21
+ #
22
+ # def update?
23
+ # user.admin? || user.id == record.id
24
+ # end
25
+ #
26
+ # def delete?
27
+ # user.admin? && record.id != user.id
28
+ # end
29
+ #
30
+ # def activate?
31
+ # user.admin? && record.pending?
32
+ # end
33
+ # end
34
+ #
35
+ # @example Use in a service
36
+ # class UserService < SmartDomain::Domain::Service
37
+ # def activate_user(user_id)
38
+ # user = User.find(user_id)
39
+ # policy = UserPolicy.new(current_user, user)
40
+ #
41
+ # authorize!(policy, :activate?)
42
+ #
43
+ # user.update!(status: 'active')
44
+ # # ... publish event ...
45
+ # end
46
+ # end
47
+ class Policy
48
+ attr_reader :user, :record
49
+
50
+ # Initialize policy
51
+ #
52
+ # @param user [Object] User performing the action (current_user)
53
+ # @param record [Object] Record being operated on
54
+ def initialize(user, record)
55
+ @user = user
56
+ @record = record
57
+ end
58
+
59
+ # Check if user can perform index action
60
+ # Override in subclass
61
+ # @return [Boolean]
62
+ def index?
63
+ false
64
+ end
65
+
66
+ # Check if user can perform show action
67
+ # Override in subclass
68
+ # @return [Boolean]
69
+ def show?
70
+ false
71
+ end
72
+
73
+ # Check if user can perform create action
74
+ # Override in subclass
75
+ # @return [Boolean]
76
+ def create?
77
+ false
78
+ end
79
+
80
+ # Check if user can perform update action
81
+ # Override in subclass
82
+ # @return [Boolean]
83
+ def update?
84
+ false
85
+ end
86
+
87
+ # Check if user can perform destroy action
88
+ # Override in subclass
89
+ # @return [Boolean]
90
+ def destroy?
91
+ false
92
+ end
93
+
94
+ # Scope for index queries
95
+ #
96
+ # Override in subclass to return a scope of records
97
+ # the user can access.
98
+ #
99
+ # @param scope [ActiveRecord::Relation] Initial scope
100
+ # @return [ActiveRecord::Relation] Filtered scope
101
+ #
102
+ # @example
103
+ # class UserPolicy < SmartDomain::Domain::Policy
104
+ # class Scope
105
+ # attr_reader :user, :scope
106
+ #
107
+ # def initialize(user, scope)
108
+ # @user = user
109
+ # @scope = scope
110
+ # end
111
+ #
112
+ # def resolve
113
+ # if user.admin?
114
+ # scope.all
115
+ # else
116
+ # scope.where(organization_id: user.organization_id)
117
+ # end
118
+ # end
119
+ # end
120
+ # end
121
+ class Scope
122
+ attr_reader :user, :scope
123
+
124
+ def initialize(user, scope)
125
+ @user = user
126
+ @scope = scope
127
+ end
128
+
129
+ # Resolve scope based on user permissions
130
+ # Override in subclass
131
+ # @return [ActiveRecord::Relation]
132
+ def resolve
133
+ scope.none
134
+ end
135
+ end
136
+
137
+ # Helper method to check if user is present
138
+ # @return [Boolean]
139
+ def user_present?
140
+ !user.nil?
141
+ end
142
+
143
+ # Helper method to check if user is admin
144
+ # Override in subclass based on your user model
145
+ # @return [Boolean]
146
+ def admin?
147
+ user_present? && user.respond_to?(:admin?) && user.admin?
148
+ end
149
+
150
+ # Helper method to check if user owns the record
151
+ # @return [Boolean]
152
+ def owner?
153
+ user_present? && record.respond_to?(:user_id) && record.user_id == user.id
154
+ end
155
+
156
+ # Helper method to check if user is in same organization
157
+ # @return [Boolean]
158
+ def same_organization?
159
+ user_present? &&
160
+ record.respond_to?(:organization_id) &&
161
+ user.respond_to?(:organization_id) &&
162
+ record.organization_id == user.organization_id
163
+ end
164
+ end
165
+
166
+ # Authorization helper methods for services
167
+ module PolicyHelpers
168
+ # Authorize an action or raise an error
169
+ #
170
+ # @param policy [SmartDomain::Domain::Policy] Policy instance
171
+ # @param action [Symbol] Action to authorize (e.g., :create?, :update?)
172
+ # @raise [SmartDomain::Domain::UnauthorizedError] If not authorized
173
+ #
174
+ # @example
175
+ # policy = UserPolicy.new(current_user, user)
176
+ # authorize!(policy, :update?)
177
+ def authorize!(policy, action)
178
+ return if policy.public_send(action)
179
+
180
+ raise SmartDomain::Domain::UnauthorizedError.new(
181
+ "Not authorized to perform #{action} on #{policy.record.class.name}",
182
+ action: action,
183
+ resource: policy.record.class.name
184
+ )
185
+ end
186
+
187
+ # Check if action is authorized
188
+ #
189
+ # @param policy [SmartDomain::Domain::Policy] Policy instance
190
+ # @param action [Symbol] Action to check
191
+ # @return [Boolean] True if authorized
192
+ #
193
+ # @example
194
+ # policy = UserPolicy.new(current_user, user)
195
+ # if authorized?(policy, :update?)
196
+ # # perform update
197
+ # end
198
+ def authorized?(policy, action)
199
+ policy.public_send(action)
200
+ end
201
+
202
+ # Get scoped records based on user permissions
203
+ #
204
+ # @param scope [ActiveRecord::Relation] Initial scope
205
+ # @param policy_class [Class] Policy class
206
+ # @return [ActiveRecord::Relation] Filtered scope
207
+ #
208
+ # @example
209
+ # users = policy_scope(User.all, UserPolicy)
210
+ def policy_scope(scope, policy_class)
211
+ policy_class::Scope.new(current_user, scope).resolve
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartDomain
4
+ module Domain
5
+ # Base class for domain services.
6
+ #
7
+ # Domain services contain business logic that doesn't naturally fit
8
+ # within a single entity. They orchestrate operations across multiple
9
+ # entities and publish domain events.
10
+ #
11
+ # Key principles:
12
+ # - Services own business logic, not controllers
13
+ # - Services publish events after successful operations
14
+ # - Services use transactions for data consistency
15
+ # - Services are stateless (except for injected context)
16
+ #
17
+ # @example Define a domain service
18
+ # class UserService < SmartDomain::Domain::Service
19
+ # def create_user(attributes)
20
+ # User.transaction do
21
+ # user = User.create!(attributes)
22
+ #
23
+ # event = UserCreatedEvent.new(
24
+ # event_type: 'user.created',
25
+ # aggregate_id: user.id,
26
+ # aggregate_type: 'User',
27
+ # organization_id: current_organization_id,
28
+ # user_id: user.id,
29
+ # email: user.email
30
+ # )
31
+ #
32
+ # publish_after_commit(event)
33
+ # user
34
+ # end
35
+ # end
36
+ # end
37
+ #
38
+ # @example Use the service in a controller
39
+ # class UsersController < ApplicationController
40
+ # def create
41
+ # service = UserService.new(
42
+ # current_user: current_user,
43
+ # organization_id: current_organization.id
44
+ # )
45
+ # @user = service.create_user(user_params)
46
+ # redirect_to @user, notice: 'User created successfully'
47
+ # end
48
+ # end
49
+ class Service
50
+ include PolicyHelpers
51
+
52
+ attr_reader :current_user, :current_user_id, :current_organization_id, :logger
53
+
54
+ # Initialize service with context
55
+ #
56
+ # @param current_user [Object, nil] Current user performing the action
57
+ # @param current_user_id [String, Integer, nil] Current user ID
58
+ # @param organization_id [String, Integer, nil] Organization/tenant ID
59
+ # @param logger [Logger, nil] Logger instance (defaults to Rails.logger)
60
+ def initialize(current_user: nil, current_user_id: nil, organization_id: nil, logger: nil)
61
+ @current_user = current_user
62
+ @current_user_id = current_user_id || current_user&.id
63
+ @current_organization_id = organization_id || current_user&.organization_id
64
+ @logger = logger || (defined?(Rails) ? Rails.logger : Logger.new($stdout))
65
+ @pending_events = []
66
+ end
67
+
68
+ # Publish event after the current transaction commits
69
+ #
70
+ # This ensures events are only published if the database transaction
71
+ # succeeds. If the transaction rolls back, events are discarded.
72
+ #
73
+ # @param event [SmartDomain::Event::Base] Event to publish
74
+ #
75
+ # @example
76
+ # def create_user(attributes)
77
+ # User.transaction do
78
+ # user = User.create!(attributes)
79
+ # event = UserCreatedEvent.new(...)
80
+ # publish_after_commit(event)
81
+ # user
82
+ # end
83
+ # end
84
+ def publish_after_commit(event)
85
+ if active_record_available? && in_transaction?
86
+ # Queue event for publishing after commit
87
+ ActiveRecord::Base.connection.after_transaction_commit do
88
+ publish_event(event)
89
+ end
90
+ else
91
+ # No transaction, publish immediately
92
+ publish_event(event)
93
+ end
94
+ end
95
+
96
+ # Publish multiple events after commit
97
+ #
98
+ # @param events [Array<SmartDomain::Event::Base>] Events to publish
99
+ def publish_all_after_commit(events)
100
+ events.each { |event| publish_after_commit(event) }
101
+ end
102
+
103
+ # Run a block within a database transaction
104
+ #
105
+ # This is a convenience method for wrapping operations in a transaction.
106
+ # Events should be published using #publish_after_commit within the block.
107
+ #
108
+ # @yield Block to run within transaction
109
+ # @return [Object] Result of the block
110
+ #
111
+ # @example
112
+ # def transfer_funds(from_account, to_account, amount)
113
+ # with_transaction do
114
+ # from_account.withdraw(amount)
115
+ # to_account.deposit(amount)
116
+ #
117
+ # event = FundsTransferredEvent.new(...)
118
+ # publish_after_commit(event)
119
+ #
120
+ # true
121
+ # end
122
+ # end
123
+ def with_transaction(&block)
124
+ if active_record_available?
125
+ ActiveRecord::Base.transaction(&block)
126
+ else
127
+ yield
128
+ end
129
+ end
130
+
131
+ # Build event with common context fields
132
+ #
133
+ # This helper method reduces boilerplate by automatically filling in
134
+ # organization_id and actor fields from the service context.
135
+ #
136
+ # @param event_class [Class] Event class to instantiate
137
+ # @param attributes [Hash] Event attributes
138
+ # @return [SmartDomain::Event::Base] Instantiated event
139
+ #
140
+ # @example
141
+ # event = build_event(UserCreatedEvent,
142
+ # event_type: 'user.created',
143
+ # aggregate_id: user.id,
144
+ # aggregate_type: 'User',
145
+ # user_id: user.id,
146
+ # email: user.email
147
+ # )
148
+ def build_event(event_class, attributes = {})
149
+ # Auto-fill organization_id if not provided
150
+ attributes[:organization_id] ||= current_organization_id
151
+
152
+ # Auto-fill actor fields if event includes ActorMixin
153
+ if event_class.instance_methods.include?(:actor_id)
154
+ attributes[:actor_id] ||= current_user_id&.to_s
155
+ attributes[:actor_email] ||= current_user&.email
156
+ end
157
+
158
+ event_class.new(attributes)
159
+ end
160
+
161
+ # Extract changes from an ActiveRecord model for ChangeTrackingMixin
162
+ #
163
+ # This helper extracts changed fields and their old/new values from
164
+ # an ActiveRecord model's saved_changes.
165
+ #
166
+ # @param record [ActiveRecord::Base] Record with changes
167
+ # @return [Hash] Hash with changed_fields, old_values, new_values
168
+ #
169
+ # @example
170
+ # user.update!(email: 'new@example.com', name: 'New Name')
171
+ # changes = extract_changes(user)
172
+ # # => {
173
+ # # changed_fields: ['email', 'name'],
174
+ # # old_values: { email: 'old@example.com', name: 'Old Name' },
175
+ # # new_values: { email: 'new@example.com', name: 'New Name' }
176
+ # # }
177
+ def extract_changes(record)
178
+ return { changed_fields: [], old_values: {}, new_values: {} } unless record.respond_to?(:saved_changes)
179
+
180
+ changes = record.saved_changes
181
+ {
182
+ changed_fields: changes.keys,
183
+ old_values: changes.transform_values(&:first),
184
+ new_values: changes.transform_values(&:last)
185
+ }
186
+ end
187
+
188
+ # Log a message with service context
189
+ #
190
+ # @param level [Symbol] Log level (:info, :warn, :error, :debug)
191
+ # @param message [String] Log message
192
+ # @param data [Hash] Additional structured data
193
+ def log(level, message, data = {})
194
+ context = {
195
+ service: self.class.name,
196
+ organization_id: current_organization_id,
197
+ user_id: current_user_id
198
+ }.merge(data)
199
+
200
+ @logger.send(level, "[#{self.class.name}] #{message} - #{context.to_json}")
201
+ end
202
+
203
+ private
204
+
205
+ # Publish an event to the event bus
206
+ # @param event [SmartDomain::Event::Base] Event to publish
207
+ def publish_event(event)
208
+ SmartDomain::Event.bus.publish(event)
209
+ rescue StandardError => e
210
+ @logger.error "[#{self.class.name}] Failed to publish event: #{e.message}"
211
+ @logger.error e.backtrace.join("\n")
212
+ raise
213
+ end
214
+
215
+ # Check if ActiveRecord is available
216
+ # @return [Boolean]
217
+ def active_record_available?
218
+ defined?(ActiveRecord::Base)
219
+ end
220
+
221
+ # Check if currently in a database transaction
222
+ # @return [Boolean]
223
+ def in_transaction?
224
+ return false unless active_record_available?
225
+
226
+ ActiveRecord::Base.connection.transaction_open?
227
+ end
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartDomain
4
+ module Event
5
+ module Adapters
6
+ # In-memory event bus adapter for synchronous event handling.
7
+ #
8
+ # This adapter stores event handlers in memory and publishes events
9
+ # synchronously. It's suitable for development, testing, and simple
10
+ # production applications.
11
+ #
12
+ # For more robust production use cases, consider Redis or ActiveJob adapters.
13
+ #
14
+ # @example
15
+ # adapter = SmartDomain::Event::Adapters::Memory.new
16
+ # adapter.subscribe('user.created', handler)
17
+ # adapter.publish(event)
18
+ class Memory
19
+ def initialize
20
+ @handlers = Hash.new { |h, k| h[k] = [] }
21
+ @logger = ActiveSupport::TaggedLogging.new(Logger.new($stdout))
22
+ end
23
+
24
+ # Subscribe a handler to an event type
25
+ # @param event_type [String] Event type to subscribe to
26
+ # @param handler [Object] Handler that responds to #handle(event)
27
+ def subscribe(event_type, handler)
28
+ @handlers[event_type] << handler unless @handlers[event_type].include?(handler)
29
+ end
30
+
31
+ # Publish an event to all subscribed handlers
32
+ # @param event [SmartDomain::Event::Base] Event to publish
33
+ def publish(event)
34
+ # Find all matching handlers (exact match + wildcard patterns)
35
+ matching_handlers = find_matching_handlers(event.event_type)
36
+
37
+ if matching_handlers.empty?
38
+ @logger.debug "[SmartDomain::Memory] No handlers for event type: #{event.event_type}"
39
+ return
40
+ end
41
+
42
+ @logger.debug "[SmartDomain::Memory] Notifying #{matching_handlers.size} handler(s) for #{event.event_type}"
43
+
44
+ matching_handlers.each do |handler|
45
+ handle_event(handler, event)
46
+ end
47
+ end
48
+
49
+ # Get all registered handlers for an event type
50
+ # @param event_type [String] Event type
51
+ # @return [Array<Object>] List of handlers
52
+ def handlers_for(event_type)
53
+ @handlers[event_type]
54
+ end
55
+
56
+ # Clear all handlers (useful for testing)
57
+ # @api private
58
+ def clear!
59
+ @handlers.clear
60
+ end
61
+
62
+ private
63
+
64
+ # Find all handlers matching the given event type
65
+ # Supports exact matches and wildcard patterns (e.g., "user.*")
66
+ # @param event_type [String] Event type to match
67
+ # @return [Array<Object>] List of matching handlers
68
+ def find_matching_handlers(event_type)
69
+ handlers = []
70
+
71
+ @handlers.each do |pattern, pattern_handlers|
72
+ if event_type_matches?(event_type, pattern)
73
+ handlers.concat(pattern_handlers)
74
+ end
75
+ end
76
+
77
+ handlers.uniq
78
+ end
79
+
80
+ # Check if event type matches a subscription pattern
81
+ # @param event_type [String] Event type to check
82
+ # @param pattern [String] Subscription pattern (may include wildcards)
83
+ # @return [Boolean] True if event type matches pattern
84
+ def event_type_matches?(event_type, pattern)
85
+ # Exact match
86
+ return true if event_type == pattern
87
+
88
+ # Wildcard pattern match (e.g., "user.*" matches "user.created")
89
+ if pattern.end_with?(".*")
90
+ prefix = pattern[0..-3] # Remove ".*"
91
+ return event_type.start_with?("#{prefix}.")
92
+ end
93
+
94
+ false
95
+ end
96
+
97
+ # Handle event with error isolation
98
+ # @param handler [Object] Handler to execute
99
+ # @param event [SmartDomain::Event::Base] Event to handle
100
+ def handle_event(handler, event)
101
+ handler.handle(event)
102
+ rescue StandardError => e
103
+ @logger.error "[SmartDomain::Memory] Handler #{handler.class.name} failed: #{e.message}"
104
+ @logger.error e.backtrace.join("\n")
105
+ # Swallow exception to not affect other handlers
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end