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