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,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartDomain
4
+ module Handlers
5
+ # Generic audit handler for domain events.
6
+ #
7
+ # This handler provides standardized audit logging for any domain.
8
+ # It logs all events with structured data and optionally writes to
9
+ # an audit_events table for compliance.
10
+ #
11
+ # Features:
12
+ # - Structured logging to Rails logger
13
+ # - Optional database audit table writes
14
+ # - Event categorization (authentication, data_access, admin_action, etc.)
15
+ # - Risk level assessment (HIGH, MEDIUM, LOW)
16
+ # - Field extraction from event mixins
17
+ #
18
+ # @example
19
+ # user_audit = SmartDomain::Handlers::AuditHandler.new('user')
20
+ # SmartDomain::Event.bus.subscribe('user.created', user_audit)
21
+ # SmartDomain::Event.bus.subscribe('user.updated', user_audit)
22
+ #
23
+ # Or use the registration helper for one-line setup:
24
+ # SmartDomain::Event::Registration.register_standard_handlers(
25
+ # domain: 'user',
26
+ # events: ['created', 'updated', 'deleted'],
27
+ # include_audit: true
28
+ # )
29
+ class AuditHandler < Event::Handler
30
+ attr_reader :domain, :logger
31
+
32
+ # Initialize audit handler for a specific domain
33
+ # @param domain [String] Domain name (e.g., 'user', 'order', 'product')
34
+ def initialize(domain)
35
+ super()
36
+ @domain = domain
37
+ @logger = SmartDomain.configuration.logger
38
+ end
39
+
40
+ # Check if this handler can handle an event type
41
+ # @param event_type [String] Event type to check
42
+ # @return [Boolean] True if event belongs to this domain
43
+ def can_handle?(event_type)
44
+ return true if @domain == "*"
45
+
46
+ event_type.start_with?("#{@domain}.")
47
+ end
48
+
49
+ # Handle audit logging for an event
50
+ # @param event [SmartDomain::Event::Base] Event to audit
51
+ def handle(event)
52
+ action = event.event_type.split(".").last
53
+
54
+ # 1. Log to Rails logger (structured)
55
+ log_audit_event(event, action)
56
+
57
+ # 2. Write to audit_events table (if configured)
58
+ write_to_audit_table(event) if SmartDomain.configuration.audit_table_enabled?
59
+ rescue StandardError => e
60
+ # Never fail on audit handler errors
61
+ @logger.warn "[SmartDomain::AuditHandler] Audit logging failed: #{e.message}"
62
+ @logger.warn e.backtrace.join("\n")
63
+ end
64
+
65
+ private
66
+
67
+ # Log event to Rails logger with structured data
68
+ # @param event [SmartDomain::Event::Base] Event to log
69
+ # @param action [String] Event action (e.g., 'created', 'updated')
70
+ def log_audit_event(event, action)
71
+ log_data = build_log_data(event)
72
+ message = "[AUDIT] #{event.aggregate_type} #{action}"
73
+ @logger.info("#{message} - #{log_data.to_json}")
74
+ end
75
+
76
+ # Build structured log data from event
77
+ # @param event [SmartDomain::Event::Base] Event to extract data from
78
+ # @return [Hash] Structured log data
79
+ def build_log_data(event)
80
+ log_data = {
81
+ audit: true,
82
+ event_id: event.event_id,
83
+ event_type: event.event_type,
84
+ aggregate_type: event.aggregate_type,
85
+ aggregate_id: event.aggregate_id,
86
+ organization_id: event.organization_id,
87
+ occurred_at: event.occurred_at.iso8601
88
+ }
89
+
90
+ # Add all event-specific fields (including mixin fields)
91
+ event.attributes.each do |key, value|
92
+ next if log_data.key?(key.to_sym) || value.nil?
93
+
94
+ log_data[key.to_sym] = serialize_value(value)
95
+ end
96
+
97
+ log_data
98
+ end
99
+
100
+ # Serialize a value for logging
101
+ # @param value [Object] Value to serialize
102
+ # @return [Object] Serialized value
103
+ def serialize_value(value)
104
+ case value
105
+ when Time, DateTime, Date
106
+ value.iso8601
107
+ when Hash, Array
108
+ value
109
+ else
110
+ value
111
+ end
112
+ end
113
+
114
+ # Write event to audit_events table for compliance
115
+ # @param event [SmartDomain::Event::Base] Event to write
116
+ def write_to_audit_table(event)
117
+ return unless defined?(AuditEvent)
118
+
119
+ AuditEvent.create!(
120
+ event_type: event.event_type.upcase.tr(".", "_"),
121
+ event_category: map_event_category(event.event_type),
122
+ user_id: extract_actor_id(event),
123
+ organization_id: event.organization_id,
124
+ ip_address: extract_ip_address(event),
125
+ user_agent: extract_user_agent(event),
126
+ old_values: extract_old_values(event),
127
+ new_values: extract_new_values(event),
128
+ occurred_at: event.occurred_at,
129
+ risk_level: assess_risk_level(event.event_type),
130
+ compliance_flags: build_compliance_flags(event)
131
+ )
132
+ rescue StandardError => e
133
+ @logger.warn "[SmartDomain::AuditHandler] Failed to write to audit table: #{e.message}"
134
+ end
135
+
136
+ # Map event type to audit category
137
+ # @param event_type [String] Event type
138
+ # @return [String] Audit category
139
+ def map_event_category(event_type)
140
+ case event_type
141
+ when /^auth\./
142
+ "authentication"
143
+ when /accessed|viewed|patient\./
144
+ "data_access"
145
+ when /created|updated|deleted|assigned|removed/
146
+ "admin_action"
147
+ else
148
+ "system_event"
149
+ end
150
+ end
151
+
152
+ # Assess risk level of event
153
+ # @param event_type [String] Event type
154
+ # @return [String] Risk level (HIGH, MEDIUM, LOW)
155
+ def assess_risk_level(event_type)
156
+ case event_type
157
+ when /suspended|deleted|revoked|failed|rejected/
158
+ "HIGH"
159
+ when /updated|changed|assigned/
160
+ "MEDIUM"
161
+ else
162
+ "LOW"
163
+ end
164
+ end
165
+
166
+ # Extract actor_id from event (ActorMixin)
167
+ # @param event [SmartDomain::Event::Base] Event
168
+ # @return [String, nil] Actor ID
169
+ def extract_actor_id(event)
170
+ event.try(:actor_id) || event.attributes["actor_id"]
171
+ end
172
+
173
+ # Extract ip_address from event (SecurityContextMixin)
174
+ # @param event [SmartDomain::Event::Base] Event
175
+ # @return [String, nil] IP address
176
+ def extract_ip_address(event)
177
+ event.try(:ip_address) || event.attributes["ip_address"]
178
+ end
179
+
180
+ # Extract user_agent from event (SecurityContextMixin)
181
+ # @param event [SmartDomain::Event::Base] Event
182
+ # @return [String, nil] User agent
183
+ def extract_user_agent(event)
184
+ event.try(:user_agent) || event.attributes["user_agent"]
185
+ end
186
+
187
+ # Extract old_values from event (ChangeTrackingMixin)
188
+ # @param event [SmartDomain::Event::Base] Event
189
+ # @return [Hash, nil] Old values
190
+ def extract_old_values(event)
191
+ event.try(:old_values) || event.attributes["old_values"]
192
+ end
193
+
194
+ # Extract new_values from event (ChangeTrackingMixin)
195
+ # @param event [SmartDomain::Event::Base] Event
196
+ # @return [Hash, nil] New values
197
+ def extract_new_values(event)
198
+ event.try(:new_values) || event.attributes["new_values"]
199
+ end
200
+
201
+ # Build compliance flags for audit record
202
+ # @param event [SmartDomain::Event::Base] Event
203
+ # @return [Hash] Compliance flags
204
+ def build_compliance_flags(event)
205
+ {
206
+ event_id: event.event_id,
207
+ aggregate_id: event.aggregate_id,
208
+ aggregate_type: event.aggregate_type,
209
+ domain: @domain,
210
+ correlation_id: event.correlation_id,
211
+ causation_id: event.causation_id
212
+ }.compact
213
+ end
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartDomain
4
+ module Handlers
5
+ # Generic metrics handler for domain events.
6
+ #
7
+ # This handler collects metrics from domain events for monitoring
8
+ # and analytics. It can integrate with metrics systems like StatsD,
9
+ # Datadog, Prometheus, or CloudWatch.
10
+ #
11
+ # By default, it logs metrics to the Rails logger. You can override
12
+ # #emit_metric to send to your metrics backend.
13
+ #
14
+ # @example
15
+ # user_metrics = SmartDomain::Handlers::MetricsHandler.new('user')
16
+ # SmartDomain::Event.bus.subscribe('user.created', user_metrics)
17
+ #
18
+ # @example Custom metrics backend
19
+ # class CustomMetricsHandler < SmartDomain::Handlers::MetricsHandler
20
+ # def emit_metric(metric_name, tags)
21
+ # StatsD.increment(metric_name, tags: tags)
22
+ # end
23
+ # end
24
+ class MetricsHandler < Event::Handler
25
+ attr_reader :domain, :logger
26
+
27
+ # Initialize metrics handler for a specific domain
28
+ # @param domain [String] Domain name (e.g., 'user', 'order', 'product')
29
+ def initialize(domain)
30
+ super()
31
+ @domain = domain
32
+ @logger = SmartDomain.configuration.logger
33
+ end
34
+
35
+ # Check if this handler can handle an event type
36
+ # @param event_type [String] Event type to check
37
+ # @return [Boolean] True if event belongs to this domain
38
+ def can_handle?(event_type)
39
+ return true if @domain == "*"
40
+
41
+ event_type.start_with?("#{@domain}.")
42
+ end
43
+
44
+ # Handle metrics collection for an event
45
+ # @param event [SmartDomain::Event::Base] Event to collect metrics from
46
+ def handle(event)
47
+ metric_name = build_metric_name(event)
48
+ tags = build_metric_tags(event)
49
+
50
+ emit_metric(metric_name, tags)
51
+ rescue StandardError => e
52
+ # Never fail on metrics handler errors
53
+ @logger.warn "[SmartDomain::MetricsHandler] Metrics collection failed: #{e.message}"
54
+ end
55
+
56
+ private
57
+
58
+ # Build metric name from event
59
+ # @param event [SmartDomain::Event::Base] Event
60
+ # @return [String] Metric name (e.g., 'domain_events.user.created')
61
+ def build_metric_name(event)
62
+ "domain_events.#{event.event_type}"
63
+ end
64
+
65
+ # Build metric tags from event
66
+ # @param event [SmartDomain::Event::Base] Event
67
+ # @return [Hash] Metric tags
68
+ def build_metric_tags(event)
69
+ {
70
+ aggregate_type: event.aggregate_type,
71
+ organization_id: event.organization_id,
72
+ domain: @domain
73
+ }
74
+ end
75
+
76
+ # Emit metric to backend
77
+ #
78
+ # Override this method to integrate with your metrics backend.
79
+ # Default implementation logs to Rails logger.
80
+ #
81
+ # @param metric_name [String] Metric name
82
+ # @param tags [Hash] Metric tags
83
+ def emit_metric(metric_name, tags)
84
+ @logger.info("[METRIC] #{metric_name} - #{tags.to_json}")
85
+
86
+ # Example integrations (commented out):
87
+ #
88
+ # StatsD:
89
+ # StatsD.increment(metric_name, tags: tags)
90
+ #
91
+ # Datadog:
92
+ # Datadog::Statsd.new.increment(metric_name, tags: tags.map { |k, v| "#{k}:#{v}" })
93
+ #
94
+ # Prometheus:
95
+ # counter = Prometheus::Client.registry.counter(
96
+ # metric_name.tr('.', '_').to_sym,
97
+ # docstring: 'Domain event counter',
98
+ # labels: tags.keys
99
+ # )
100
+ # counter.increment(labels: tags)
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartDomain
4
+ module Integration
5
+ # ActiveRecord integration for domain events.
6
+ #
7
+ # This module provides ActiveRecord models with the ability to queue
8
+ # and publish domain events after transactions commit successfully.
9
+ #
10
+ # Key features:
11
+ # - Events are queued during the request/transaction
12
+ # - Events are published AFTER the database transaction commits
13
+ # - If transaction rolls back, events are discarded
14
+ # - Thread-safe event queue per model instance
15
+ #
16
+ # @example Include in a model
17
+ # class User < ApplicationRecord
18
+ # include SmartDomain::Integration::ActiveRecord
19
+ #
20
+ # after_create :publish_created_event
21
+ # after_update :publish_updated_event
22
+ #
23
+ # private
24
+ #
25
+ # def publish_created_event
26
+ # add_domain_event(UserCreatedEvent.new(
27
+ # event_type: 'user.created',
28
+ # aggregate_id: id,
29
+ # aggregate_type: 'User',
30
+ # organization_id: organization_id,
31
+ # user_id: id,
32
+ # email: email
33
+ # ))
34
+ # end
35
+ #
36
+ # def publish_updated_event
37
+ # add_domain_event(UserUpdatedEvent.new(
38
+ # event_type: 'user.updated',
39
+ # aggregate_id: id,
40
+ # aggregate_type: 'User',
41
+ # organization_id: organization_id,
42
+ # user_id: id,
43
+ # changed_fields: saved_changes.keys,
44
+ # old_values: saved_changes.transform_values(&:first),
45
+ # new_values: saved_changes.transform_values(&:last)
46
+ # ))
47
+ # end
48
+ # end
49
+ module ActiveRecord
50
+ extend ActiveSupport::Concern
51
+
52
+ included do
53
+ # Register after_commit callback to publish events
54
+ after_commit :publish_domain_events
55
+
56
+ # Register after_rollback callback to clear events
57
+ after_rollback :clear_domain_events
58
+ end
59
+
60
+ # Queue a domain event for publishing after commit
61
+ #
62
+ # Events are stored in an instance variable and published
63
+ # when the transaction commits successfully.
64
+ #
65
+ # @param event [SmartDomain::Event::Base] Event to queue
66
+ #
67
+ # @example
68
+ # user = User.new(email: 'test@example.com')
69
+ # user.save!
70
+ #
71
+ # # In after_create callback
72
+ # event = UserCreatedEvent.new(...)
73
+ # add_domain_event(event)
74
+ def add_domain_event(event)
75
+ @pending_domain_events ||= []
76
+ @pending_domain_events << event
77
+ end
78
+
79
+ # Queue multiple domain events
80
+ #
81
+ # @param events [Array<SmartDomain::Event::Base>] Events to queue
82
+ def add_domain_events(events)
83
+ events.each { |event| add_domain_event(event) }
84
+ end
85
+
86
+ # Get pending events (useful for debugging/testing)
87
+ #
88
+ # @return [Array<SmartDomain::Event::Base>] Queued events
89
+ def pending_domain_events
90
+ @pending_domain_events || []
91
+ end
92
+
93
+ # Build an event with automatic field population
94
+ #
95
+ # This helper automatically fills in common event fields from the model:
96
+ # - aggregate_id: Uses model's id
97
+ # - aggregate_type: Uses model's class name
98
+ # - organization_id: Uses model's organization_id (if present)
99
+ #
100
+ # @param event_class [Class] Event class to instantiate
101
+ # @param attributes [Hash] Additional event attributes
102
+ # @return [SmartDomain::Event::Base] Instantiated event
103
+ #
104
+ # @example
105
+ # event = build_domain_event(UserCreatedEvent,
106
+ # event_type: 'user.created',
107
+ # user_id: id,
108
+ # email: email
109
+ # )
110
+ # add_domain_event(event)
111
+ def build_domain_event(event_class, attributes = {})
112
+ # Auto-fill aggregate fields
113
+ attributes[:aggregate_id] ||= id.to_s
114
+ attributes[:aggregate_type] ||= self.class.name
115
+
116
+ # Auto-fill organization_id if model has it
117
+ if respond_to?(:organization_id) && organization_id.present?
118
+ attributes[:organization_id] ||= organization_id.to_s
119
+ end
120
+
121
+ event_class.new(attributes)
122
+ end
123
+
124
+ # Helper to extract changes for ChangeTrackingMixin
125
+ #
126
+ # @return [Hash] Hash with changed_fields, old_values, new_values
127
+ def domain_event_changes
128
+ return { changed_fields: [], old_values: {}, new_values: {} } unless respond_to?(:saved_changes)
129
+
130
+ changes = saved_changes
131
+ {
132
+ changed_fields: changes.keys,
133
+ old_values: changes.transform_values(&:first),
134
+ new_values: changes.transform_values(&:last)
135
+ }
136
+ end
137
+
138
+ private
139
+
140
+ # Publish all pending events after commit
141
+ #
142
+ # This callback is automatically registered when the module is included.
143
+ # It publishes all queued events to the event bus and clears the queue.
144
+ def publish_domain_events
145
+ return if @pending_domain_events.blank?
146
+
147
+ @pending_domain_events.each do |event|
148
+ begin
149
+ SmartDomain::Event.bus.publish(event)
150
+ rescue StandardError => e
151
+ # Log error but don't raise (events should be fire-and-forget)
152
+ logger = defined?(Rails) ? Rails.logger : Logger.new($stdout)
153
+ logger.error "[SmartDomain] Failed to publish event: #{e.message}"
154
+ logger.error e.backtrace.join("\n")
155
+ end
156
+ end
157
+
158
+ clear_domain_events
159
+ end
160
+
161
+ # Clear pending events
162
+ #
163
+ # Called after successful publish or after transaction rollback
164
+ def clear_domain_events
165
+ @pending_domain_events = []
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartDomain
4
+ module Integration
5
+ # Multi-tenancy support for SmartDomain.
6
+ #
7
+ # Provides thread-safe tenant context management for multi-tenant applications.
8
+ # The current tenant is stored in thread-local storage to ensure isolation
9
+ # between concurrent requests.
10
+ #
11
+ # @example Set tenant in a controller
12
+ # class ApplicationController < ActionController::Base
13
+ # around_action :set_current_tenant
14
+ #
15
+ # private
16
+ #
17
+ # def set_current_tenant
18
+ # SmartDomain::Integration::TenantContext.with_tenant(current_organization.id) do
19
+ # yield
20
+ # end
21
+ # end
22
+ # end
23
+ #
24
+ # @example Use in service
25
+ # class UserService < SmartDomain::Domain::Service
26
+ # def create_user(attributes)
27
+ # tenant_id = SmartDomain::Integration::TenantContext.current
28
+ # # ... use tenant_id ...
29
+ # end
30
+ # end
31
+ module TenantContext
32
+ # Get the current tenant ID from thread-local storage
33
+ #
34
+ # @return [String, Integer, nil] Current tenant ID
35
+ def self.current
36
+ Thread.current[:smart_domain_tenant_id]
37
+ end
38
+
39
+ # Set the current tenant ID
40
+ #
41
+ # @param tenant_id [String, Integer, nil] Tenant ID to set
42
+ def self.current=(tenant_id)
43
+ Thread.current[:smart_domain_tenant_id] = tenant_id
44
+ end
45
+
46
+ # Execute a block within a tenant context
47
+ #
48
+ # Ensures the previous tenant is restored after the block executes,
49
+ # even if an exception is raised.
50
+ #
51
+ # @param tenant_id [String, Integer] Tenant ID
52
+ # @yield Block to execute within tenant context
53
+ # @return [Object] Result of the block
54
+ #
55
+ # @example
56
+ # TenantContext.with_tenant('org-123') do
57
+ # user = User.create!(email: 'test@example.com')
58
+ # # user.organization_id will be 'org-123'
59
+ # end
60
+ def self.with_tenant(tenant_id)
61
+ previous_tenant = current
62
+ self.current = tenant_id
63
+ yield
64
+ ensure
65
+ self.current = previous_tenant
66
+ end
67
+
68
+ # Clear the current tenant
69
+ def self.clear!
70
+ Thread.current[:smart_domain_tenant_id] = nil
71
+ end
72
+
73
+ # Check if a tenant is set
74
+ #
75
+ # @return [Boolean]
76
+ def self.tenant_set?
77
+ current.present?
78
+ end
79
+ end
80
+
81
+ # ActiveRecord concern for automatic tenant assignment
82
+ #
83
+ # @example Include in a model
84
+ # class User < ApplicationRecord
85
+ # include SmartDomain::Integration::TenantScoped
86
+ #
87
+ # # organization_id will be automatically set from TenantContext.current
88
+ # end
89
+ module TenantScoped
90
+ extend ActiveSupport::Concern
91
+
92
+ included do
93
+ # Set tenant on create if not already set
94
+ before_validation :set_tenant_from_context, on: :create
95
+
96
+ # Validate tenant is present
97
+ validates SmartDomain.configuration.tenant_key, presence: true
98
+
99
+ # Default scope to current tenant (optional - can be disabled)
100
+ # default_scope -> { where(organization_id: TenantContext.current) if TenantContext.tenant_set? }
101
+ end
102
+
103
+ private
104
+
105
+ # Set tenant from thread-local context
106
+ def set_tenant_from_context
107
+ tenant_key = SmartDomain.configuration.tenant_key
108
+ return if public_send(tenant_key).present?
109
+ return unless TenantContext.tenant_set?
110
+
111
+ public_send("#{tenant_key}=", TenantContext.current)
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartDomain
4
+ # Rails integration via Railtie
5
+ #
6
+ # This Railtie automatically integrates SmartDomain with Rails:
7
+ # - Adds lib/generators to generator paths
8
+ # - Auto-loads domain setup files on boot
9
+ # - Provides rake tasks
10
+ class Railtie < Rails::Railtie
11
+ railtie_name :smart_domain
12
+
13
+ # Add generators to load path
14
+ generators do
15
+ require_relative "generators/install_generator"
16
+ require_relative "generators/domain_generator"
17
+ end
18
+
19
+ # Configuration hook
20
+ config.smart_domain = SmartDomain.configuration
21
+
22
+ # Initialize SmartDomain after Rails is loaded
23
+ config.after_initialize do
24
+ # Auto-load domain setup files from app/domains/**/setup.rb
25
+ load_domain_setups if defined?(Rails.root)
26
+ end
27
+
28
+ # Rake tasks
29
+ rake_tasks do
30
+ load "smart_domain/tasks/domains.rake"
31
+ end
32
+
33
+ private
34
+
35
+ # Load all domain setup files
36
+ def self.load_domain_setups
37
+ setup_files = Dir[Rails.root.join("app/domains/**/setup.rb")]
38
+
39
+ setup_files.each do |setup_file|
40
+ begin
41
+ require setup_file
42
+
43
+ # Extract domain module name from path
44
+ # e.g., app/domains/user_management/setup.rb -> UserManagement
45
+ domain_path = setup_file.gsub(Rails.root.join("app/domains/").to_s, "")
46
+ domain_name = domain_path.split("/").first.camelize
47
+
48
+ # Call setup! method if defined
49
+ domain_module = domain_name.constantize rescue nil
50
+ if domain_module && domain_module.respond_to?(:setup!)
51
+ domain_module.setup!
52
+ Rails.logger.info "[SmartDomain] Loaded domain: #{domain_name}"
53
+ end
54
+ rescue StandardError => e
55
+ Rails.logger.error "[SmartDomain] Failed to load domain setup: #{setup_file}"
56
+ Rails.logger.error e.message
57
+ Rails.logger.error e.backtrace.join("\n")
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end