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,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
|