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
data/README.md
ADDED
|
@@ -0,0 +1,1219 @@
|
|
|
1
|
+
# SmartDomain
|
|
2
|
+
|
|
3
|
+
**Domain-Driven Design and Event-Driven Architecture for Rails**
|
|
4
|
+
|
|
5
|
+
SmartDomain brings battle-tested DDD/EDA patterns from platform to Ruby on Rails applications. It provides domain events, an event bus, generic handlers, and Rails generators for rapid domain scaffolding.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- ✅ **Domain Events** - Immutable domain events with type safety
|
|
10
|
+
- ✅ **Event Bus** - Publish-subscribe pattern for decoupled communication
|
|
11
|
+
- ✅ **Event Mixins** - Reusable event fields (Actor, Audit, ChangeTracking, SecurityContext, Reason)
|
|
12
|
+
- ✅ **Generic Handlers** - 70% boilerplate reduction for audit and metrics
|
|
13
|
+
- ✅ **Rails Integration** - Seamless integration with ActiveRecord and Rails ecosystem
|
|
14
|
+
- ✅ **Rails Generators** - Scaffold complete domains with one command (coming soon)
|
|
15
|
+
- ✅ **Multi-tenancy Support** - Built-in support for multi-tenant applications
|
|
16
|
+
- ✅ **Audit Compliance** - Automatic audit logging for compliance requirements
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
Add this line to your application's Gemfile:
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
gem 'smart_domain'
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
And then execute:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
bundle install
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Run the install generator:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
rails generate smart_domain:install
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
This creates:
|
|
39
|
+
- `config/initializers/smart_domain.rb` - Configuration file
|
|
40
|
+
- `app/events/application_event.rb` - Base event class
|
|
41
|
+
- `app/policies/application_policy.rb` - Base policy class
|
|
42
|
+
- `app/services/application_service.rb` - Base service class
|
|
43
|
+
- `app/domains/` - Domain directory structure
|
|
44
|
+
|
|
45
|
+
## Quick Start
|
|
46
|
+
|
|
47
|
+
### 1. Configure SmartDomain
|
|
48
|
+
|
|
49
|
+
Create an initializer in `config/initializers/smart_domain.rb`:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
SmartDomain.configure do |config|
|
|
53
|
+
config.event_bus_adapter = :memory # or :redis, :active_job
|
|
54
|
+
config.audit_table_enabled = true # Enable audit table writes
|
|
55
|
+
config.multi_tenancy_enabled = true # Enable multi-tenancy
|
|
56
|
+
config.tenant_key = :organization_id
|
|
57
|
+
config.async_handlers = false # Use ActiveJob for async handlers
|
|
58
|
+
config.logger = Rails.logger
|
|
59
|
+
end
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### 2. Define Domain Events
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
# app/events/user_created_event.rb
|
|
66
|
+
class UserCreatedEvent < SmartDomain::Event::Base
|
|
67
|
+
attribute :user_id, :string
|
|
68
|
+
attribute :email, :string
|
|
69
|
+
|
|
70
|
+
validates :user_id, :email, presence: true
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# app/events/user_updated_event.rb
|
|
74
|
+
class UserUpdatedEvent < SmartDomain::Event::Base
|
|
75
|
+
include SmartDomain::Event::ActorMixin # Who
|
|
76
|
+
include SmartDomain::Event::ChangeTrackingMixin # What changed
|
|
77
|
+
|
|
78
|
+
attribute :user_id, :string
|
|
79
|
+
|
|
80
|
+
validates :user_id, presence: true
|
|
81
|
+
end
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### 3. Publish Events
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
# In your service or model
|
|
88
|
+
user = User.create!(email: 'user@example.com', name: 'John Doe')
|
|
89
|
+
|
|
90
|
+
event = UserCreatedEvent.new(
|
|
91
|
+
event_type: 'user.created',
|
|
92
|
+
aggregate_id: user.id,
|
|
93
|
+
aggregate_type: 'User',
|
|
94
|
+
organization_id: current_organization.id,
|
|
95
|
+
user_id: user.id,
|
|
96
|
+
email: user.email
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
SmartDomain::Event.bus.publish(event)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### 4. Register Event Handlers (The Magic!)
|
|
103
|
+
|
|
104
|
+
Instead of manually subscribing audit and metrics handlers to each event:
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
# ❌ OLD WAY - Lots of boilerplate
|
|
108
|
+
audit = AuditHandler.new('user')
|
|
109
|
+
metrics = MetricsHandler.new('user')
|
|
110
|
+
SmartDomain::Event.bus.subscribe('user.created', audit)
|
|
111
|
+
SmartDomain::Event.bus.subscribe('user.created', metrics)
|
|
112
|
+
SmartDomain::Event.bus.subscribe('user.updated', audit)
|
|
113
|
+
SmartDomain::Event.bus.subscribe('user.updated', metrics)
|
|
114
|
+
# ... repeat for all events ...
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Use this **one-liner** for 70% boilerplate reduction:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
# ✅ NEW WAY - One line for all standard handlers
|
|
121
|
+
SmartDomain::Event::Registration.register_standard_handlers(
|
|
122
|
+
domain: 'user',
|
|
123
|
+
events: %w[created updated deleted activated suspended],
|
|
124
|
+
include_audit: true,
|
|
125
|
+
include_metrics: true
|
|
126
|
+
)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### 5. Create Custom Event Handlers
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
# app/handlers/user_email_handler.rb
|
|
133
|
+
class UserEmailHandler < SmartDomain::Event::Handler
|
|
134
|
+
def handle(event)
|
|
135
|
+
case event.event_type
|
|
136
|
+
when 'user.created'
|
|
137
|
+
UserMailer.welcome_email(event.user_id).deliver_later
|
|
138
|
+
when 'user.activated'
|
|
139
|
+
UserMailer.account_activated(event.user_id).deliver_later
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def can_handle?(event_type)
|
|
144
|
+
['user.created', 'user.activated'].include?(event_type)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Register custom handler
|
|
149
|
+
email_handler = UserEmailHandler.new
|
|
150
|
+
SmartDomain::Event.bus.subscribe('user.created', email_handler)
|
|
151
|
+
SmartDomain::Event.bus.subscribe('user.activated', email_handler)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Core Concepts
|
|
155
|
+
|
|
156
|
+
### Domain Events
|
|
157
|
+
|
|
158
|
+
Domain events represent significant business occurrences in your application. They are:
|
|
159
|
+
|
|
160
|
+
- **Immutable** - Once created, events cannot be modified
|
|
161
|
+
- **Type-safe** - Uses ActiveModel::Attributes for type coercion
|
|
162
|
+
- **Validated** - ActiveModel validations ensure event integrity
|
|
163
|
+
- **Structured** - Standardized fields across all events
|
|
164
|
+
|
|
165
|
+
**Core Event Fields:**
|
|
166
|
+
- `event_id` - Unique event identifier (UUID)
|
|
167
|
+
- `event_type` - Event type (e.g., 'user.created')
|
|
168
|
+
- `aggregate_id` - ID of the entity that produced the event
|
|
169
|
+
- `aggregate_type` - Type of entity (e.g., 'User', 'Order')
|
|
170
|
+
- `organization_id` - Tenant/organization context
|
|
171
|
+
- `occurred_at` - Timestamp when event occurred
|
|
172
|
+
- `version` - Event schema version
|
|
173
|
+
- `correlation_id` - For tracing related events
|
|
174
|
+
- `causation_id` - Event that caused this event
|
|
175
|
+
- `metadata` - Additional data
|
|
176
|
+
|
|
177
|
+
### Event Mixins
|
|
178
|
+
|
|
179
|
+
Event mixins provide reusable fields for common patterns:
|
|
180
|
+
|
|
181
|
+
#### ActorMixin - WHO performed the action
|
|
182
|
+
```ruby
|
|
183
|
+
class UserSuspendedEvent < SmartDomain::Event::Base
|
|
184
|
+
include SmartDomain::Event::ActorMixin
|
|
185
|
+
|
|
186
|
+
# Provides: actor_id, actor_email
|
|
187
|
+
end
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
#### ChangeTrackingMixin - WHAT changed
|
|
191
|
+
```ruby
|
|
192
|
+
class UserUpdatedEvent < SmartDomain::Event::Base
|
|
193
|
+
include SmartDomain::Event::ChangeTrackingMixin
|
|
194
|
+
|
|
195
|
+
# Provides: changed_fields, old_values, new_values
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Usage
|
|
199
|
+
event = UserUpdatedEvent.new(
|
|
200
|
+
...,
|
|
201
|
+
changed_fields: ['email', 'name'],
|
|
202
|
+
old_values: { email: 'old@example.com', name: 'Old Name' },
|
|
203
|
+
new_values: { email: 'new@example.com', name: 'New Name' }
|
|
204
|
+
)
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
#### SecurityContextMixin - WHERE from
|
|
208
|
+
```ruby
|
|
209
|
+
class UserLoggedInEvent < SmartDomain::Event::Base
|
|
210
|
+
include SmartDomain::Event::SecurityContextMixin
|
|
211
|
+
|
|
212
|
+
# Provides: ip_address, user_agent
|
|
213
|
+
end
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
#### ReasonMixin - WHY
|
|
217
|
+
```ruby
|
|
218
|
+
class UserBannedEvent < SmartDomain::Event::Base
|
|
219
|
+
include SmartDomain::Event::ReasonMixin
|
|
220
|
+
|
|
221
|
+
# Provides: reason
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
event = UserBannedEvent.new(
|
|
225
|
+
...,
|
|
226
|
+
reason: 'Violation of terms of service - spam activity detected'
|
|
227
|
+
)
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### Event Bus
|
|
231
|
+
|
|
232
|
+
The event bus follows the **publish-subscribe pattern** for decoupled communication.
|
|
233
|
+
|
|
234
|
+
**Features:**
|
|
235
|
+
- Synchronous by default (in-memory)
|
|
236
|
+
- Pluggable adapters (Memory, Redis, ActiveJob)
|
|
237
|
+
- Error isolation (handler failures don't affect other handlers)
|
|
238
|
+
- Structured logging
|
|
239
|
+
|
|
240
|
+
### Generic Handlers
|
|
241
|
+
|
|
242
|
+
Generic handlers provide cross-cutting concerns for all domains:
|
|
243
|
+
|
|
244
|
+
#### AuditHandler
|
|
245
|
+
- Logs all events to Rails logger with structured data
|
|
246
|
+
- Optionally writes to audit_events table for compliance
|
|
247
|
+
- Categorizes events (authentication, data_access, admin_action, system_event)
|
|
248
|
+
- Assesses risk level (HIGH, MEDIUM, LOW)
|
|
249
|
+
- Extracts fields from event mixins (actor_id, ip_address, old_values, etc.)
|
|
250
|
+
|
|
251
|
+
#### MetricsHandler
|
|
252
|
+
- Collects metrics from domain events
|
|
253
|
+
- Integrates with metrics systems (StatsD, Datadog, Prometheus, CloudWatch)
|
|
254
|
+
- Provides metric name and tags for easy querying
|
|
255
|
+
|
|
256
|
+
### Hybrid Event Approach
|
|
257
|
+
|
|
258
|
+
The hybrid approach combines explicit event definitions with generic infrastructure:
|
|
259
|
+
|
|
260
|
+
**Before (70 lines of repetitive code):**
|
|
261
|
+
```ruby
|
|
262
|
+
class UserAuditHandler < SmartDomain::Event::Handler
|
|
263
|
+
def handle(event)
|
|
264
|
+
# Audit logging logic...
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
class UserMetricsHandler < SmartDomain::Event::Handler
|
|
269
|
+
def handle(event)
|
|
270
|
+
# Metrics logic...
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Manual subscription
|
|
275
|
+
audit = UserAuditHandler.new
|
|
276
|
+
metrics = UserMetricsHandler.new
|
|
277
|
+
bus.subscribe('user.created', audit)
|
|
278
|
+
bus.subscribe('user.updated', audit)
|
|
279
|
+
bus.subscribe('user.created', metrics)
|
|
280
|
+
bus.subscribe('user.updated', metrics)
|
|
281
|
+
# ... 50 more lines ...
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
**After (1 line with `register_standard_handlers`):**
|
|
285
|
+
```ruby
|
|
286
|
+
SmartDomain::Event::Registration.register_standard_handlers(
|
|
287
|
+
domain: 'user',
|
|
288
|
+
events: %w[created updated deleted],
|
|
289
|
+
include_audit: true,
|
|
290
|
+
include_metrics: true
|
|
291
|
+
)
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
**Result:** 70% less boilerplate, validated in production at Aeyes!
|
|
295
|
+
|
|
296
|
+
## Architecture Patterns
|
|
297
|
+
|
|
298
|
+
### Domain-Driven Design (DDD)
|
|
299
|
+
|
|
300
|
+
SmartDomain encourages organizing code by **bounded contexts** (domains):
|
|
301
|
+
|
|
302
|
+
```
|
|
303
|
+
app/domains/
|
|
304
|
+
├── user_management/
|
|
305
|
+
│ ├── user_service.rb
|
|
306
|
+
│ ├── user_events.rb
|
|
307
|
+
│ ├── user_handlers.rb
|
|
308
|
+
│ ├── user_policy.rb
|
|
309
|
+
│ └── setup.rb
|
|
310
|
+
├── order_management/
|
|
311
|
+
│ ├── order_service.rb
|
|
312
|
+
│ ├── order_events.rb
|
|
313
|
+
│ └── setup.rb
|
|
314
|
+
└── ...
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
### Event-Driven Architecture (EDA)
|
|
318
|
+
|
|
319
|
+
**Principles:**
|
|
320
|
+
1. **Events are mandatory** - Every significant business action publishes an event
|
|
321
|
+
2. **Publish after commit** - Events published after database transaction commits
|
|
322
|
+
3. **Cross-domain communication via events** - Domains don't call each other directly
|
|
323
|
+
4. **Fire-and-forget** - Event publishing is asynchronous from handling
|
|
324
|
+
|
|
325
|
+
**Example:**
|
|
326
|
+
```ruby
|
|
327
|
+
# ✅ GOOD: Publish event
|
|
328
|
+
ActiveRecord::Base.transaction do
|
|
329
|
+
user = User.create!(params)
|
|
330
|
+
event = UserCreatedEvent.new(...)
|
|
331
|
+
|
|
332
|
+
# Publish AFTER commit
|
|
333
|
+
ActiveRecord::Base.connection.after_transaction_commit do
|
|
334
|
+
SmartDomain::Event.bus.publish(event)
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# ❌ BAD: Direct cross-domain call
|
|
339
|
+
OrderService.cancel_user_orders(user.id) # Tight coupling!
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
## Configuration Options
|
|
343
|
+
|
|
344
|
+
```ruby
|
|
345
|
+
SmartDomain.configure do |config|
|
|
346
|
+
# Event bus adapter (:memory, :redis, :active_job)
|
|
347
|
+
config.event_bus_adapter = :memory
|
|
348
|
+
|
|
349
|
+
# Enable automatic writes to audit_events table
|
|
350
|
+
config.audit_table_enabled = true
|
|
351
|
+
|
|
352
|
+
# Enable multi-tenancy support
|
|
353
|
+
config.multi_tenancy_enabled = true
|
|
354
|
+
|
|
355
|
+
# Key used for tenant identification
|
|
356
|
+
config.tenant_key = :organization_id
|
|
357
|
+
|
|
358
|
+
# Use ActiveJob for asynchronous event handling
|
|
359
|
+
config.async_handlers = false
|
|
360
|
+
|
|
361
|
+
# Logger instance
|
|
362
|
+
config.logger = Rails.logger
|
|
363
|
+
end
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
## Audit Table Schema
|
|
367
|
+
|
|
368
|
+
If `audit_table_enabled` is true, create an `audit_events` table:
|
|
369
|
+
|
|
370
|
+
```ruby
|
|
371
|
+
create_table :audit_events do |t|
|
|
372
|
+
t.string :event_type, null: false
|
|
373
|
+
t.string :event_category, null: false
|
|
374
|
+
t.bigint :user_id
|
|
375
|
+
t.string :organization_id
|
|
376
|
+
t.string :ip_address
|
|
377
|
+
t.text :user_agent
|
|
378
|
+
t.json :old_values
|
|
379
|
+
t.json :new_values
|
|
380
|
+
t.datetime :occurred_at, null: false
|
|
381
|
+
t.string :risk_level
|
|
382
|
+
t.json :compliance_flags
|
|
383
|
+
|
|
384
|
+
t.timestamps
|
|
385
|
+
|
|
386
|
+
t.index :event_type
|
|
387
|
+
t.index :event_category
|
|
388
|
+
t.index :user_id
|
|
389
|
+
t.index :organization_id
|
|
390
|
+
t.index :occurred_at
|
|
391
|
+
end
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
## Testing
|
|
395
|
+
|
|
396
|
+
Testing events and handlers is straightforward:
|
|
397
|
+
|
|
398
|
+
```ruby
|
|
399
|
+
# spec/support/event_helpers.rb
|
|
400
|
+
module EventHelpers
|
|
401
|
+
def published_events
|
|
402
|
+
@published_events ||= []
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def expect_event(event_type)
|
|
406
|
+
expect(published_events.map(&:event_type)).to include(event_type)
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def stub_event_bus
|
|
410
|
+
allow(SmartDomain::Event.bus).to receive(:publish) do |event|
|
|
411
|
+
published_events << event
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
RSpec.configure do |config|
|
|
417
|
+
config.include EventHelpers
|
|
418
|
+
|
|
419
|
+
config.before(:each) do
|
|
420
|
+
@published_events = []
|
|
421
|
+
stub_event_bus
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# spec/services/user_service_spec.rb
|
|
426
|
+
RSpec.describe UserService do
|
|
427
|
+
it 'publishes user.created event' do
|
|
428
|
+
service = UserService.new
|
|
429
|
+
user = service.create_user(email: 'test@example.com')
|
|
430
|
+
|
|
431
|
+
expect(user).to be_persisted
|
|
432
|
+
expect_event('user.created')
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
## Domain Service Pattern
|
|
438
|
+
|
|
439
|
+
Domain services encapsulate business logic that doesn't naturally fit within a single entity. They follow these principles:
|
|
440
|
+
|
|
441
|
+
1. **Services own business logic** - Controllers delegate to services
|
|
442
|
+
2. **Services publish events** - After successful operations
|
|
443
|
+
3. **Services use transactions** - For data consistency
|
|
444
|
+
4. **Services are stateless** - Except for injected context
|
|
445
|
+
|
|
446
|
+
### Creating a Domain Service
|
|
447
|
+
|
|
448
|
+
```ruby
|
|
449
|
+
# app/services/user_service.rb
|
|
450
|
+
class UserService < SmartDomain::Domain::Service
|
|
451
|
+
def create_user(attributes)
|
|
452
|
+
# Validate input
|
|
453
|
+
raise ValidationError.new('Email is required') if attributes[:email].blank?
|
|
454
|
+
|
|
455
|
+
User.transaction do
|
|
456
|
+
# Check business rules
|
|
457
|
+
if User.exists?(email: attributes[:email])
|
|
458
|
+
raise AlreadyExistsError.new('User', 'email', attributes[:email])
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# Create entity
|
|
462
|
+
user = User.create!(attributes)
|
|
463
|
+
|
|
464
|
+
# Build and publish event
|
|
465
|
+
event = build_event(UserCreatedEvent,
|
|
466
|
+
event_type: 'user.created',
|
|
467
|
+
aggregate_id: user.id,
|
|
468
|
+
aggregate_type: 'User',
|
|
469
|
+
user_id: user.id,
|
|
470
|
+
email: user.email
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
publish_after_commit(event)
|
|
474
|
+
user
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def update_user(user_id, attributes)
|
|
479
|
+
user = User.find(user_id)
|
|
480
|
+
|
|
481
|
+
# Authorization
|
|
482
|
+
policy = UserPolicy.new(current_user, user)
|
|
483
|
+
authorize!(policy, :update?)
|
|
484
|
+
|
|
485
|
+
User.transaction do
|
|
486
|
+
user.update!(attributes)
|
|
487
|
+
|
|
488
|
+
# Extract changes for event
|
|
489
|
+
changes = extract_changes(user)
|
|
490
|
+
|
|
491
|
+
event = build_event(UserUpdatedEvent,
|
|
492
|
+
event_type: 'user.updated',
|
|
493
|
+
aggregate_id: user.id,
|
|
494
|
+
aggregate_type: 'User',
|
|
495
|
+
user_id: user.id,
|
|
496
|
+
**changes # Includes changed_fields, old_values, new_values
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
publish_after_commit(event)
|
|
500
|
+
user
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def activate_user(user_id)
|
|
505
|
+
user = User.find(user_id)
|
|
506
|
+
|
|
507
|
+
# Business rule validation
|
|
508
|
+
unless user.pending?
|
|
509
|
+
raise InvalidStateError.new('User',
|
|
510
|
+
from: user.status,
|
|
511
|
+
to: 'active',
|
|
512
|
+
reason: 'User must be pending to activate'
|
|
513
|
+
)
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
User.transaction do
|
|
517
|
+
user.update!(status: 'active')
|
|
518
|
+
|
|
519
|
+
event = build_event(UserActivatedEvent,
|
|
520
|
+
event_type: 'user.activated',
|
|
521
|
+
aggregate_id: user.id,
|
|
522
|
+
aggregate_type: 'User',
|
|
523
|
+
user_id: user.id
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
publish_after_commit(event)
|
|
527
|
+
user
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
### Using Services in Controllers
|
|
534
|
+
|
|
535
|
+
```ruby
|
|
536
|
+
class UsersController < ApplicationController
|
|
537
|
+
def create
|
|
538
|
+
service = UserService.new(
|
|
539
|
+
current_user: current_user,
|
|
540
|
+
organization_id: current_organization.id
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
@user = service.create_user(user_params)
|
|
544
|
+
redirect_to @user, notice: 'User created successfully'
|
|
545
|
+
rescue SmartDomain::Domain::ValidationError => e
|
|
546
|
+
flash[:error] = e.message
|
|
547
|
+
render :new
|
|
548
|
+
rescue SmartDomain::Domain::AlreadyExistsError => e
|
|
549
|
+
flash[:error] = e.message
|
|
550
|
+
render :new
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def update
|
|
554
|
+
service = UserService.new(current_user: current_user)
|
|
555
|
+
@user = service.update_user(params[:id], user_params)
|
|
556
|
+
redirect_to @user, notice: 'User updated successfully'
|
|
557
|
+
rescue SmartDomain::Domain::UnauthorizedError
|
|
558
|
+
redirect_to root_path, alert: 'You are not authorized to perform this action'
|
|
559
|
+
end
|
|
560
|
+
end
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
### Service Helper Methods
|
|
564
|
+
|
|
565
|
+
The `Domain::Service` base class provides several helper methods:
|
|
566
|
+
|
|
567
|
+
#### `build_event(event_class, attributes)`
|
|
568
|
+
Automatically fills in `organization_id` and actor fields from service context:
|
|
569
|
+
|
|
570
|
+
```ruby
|
|
571
|
+
event = build_event(UserCreatedEvent,
|
|
572
|
+
event_type: 'user.created',
|
|
573
|
+
aggregate_id: user.id,
|
|
574
|
+
aggregate_type: 'User',
|
|
575
|
+
user_id: user.id,
|
|
576
|
+
email: user.email
|
|
577
|
+
# organization_id, actor_id, actor_email filled automatically!
|
|
578
|
+
)
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
#### `extract_changes(record)`
|
|
582
|
+
Extracts changed fields from ActiveRecord model for ChangeTrackingMixin:
|
|
583
|
+
|
|
584
|
+
```ruby
|
|
585
|
+
user.update!(email: 'new@example.com')
|
|
586
|
+
changes = extract_changes(user)
|
|
587
|
+
# => {
|
|
588
|
+
# changed_fields: ['email'],
|
|
589
|
+
# old_values: { email: 'old@example.com' },
|
|
590
|
+
# new_values: { email: 'new@example.com' }
|
|
591
|
+
# }
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
#### `authorize!(policy, action)`
|
|
595
|
+
Check authorization and raise error if not authorized:
|
|
596
|
+
|
|
597
|
+
```ruby
|
|
598
|
+
policy = UserPolicy.new(current_user, user)
|
|
599
|
+
authorize!(policy, :update?) # Raises UnauthorizedError if not allowed
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
#### `with_transaction(&block)`
|
|
603
|
+
Wrap operations in a database transaction:
|
|
604
|
+
|
|
605
|
+
```ruby
|
|
606
|
+
with_transaction do
|
|
607
|
+
user = User.create!(attributes)
|
|
608
|
+
profile = Profile.create!(user: user, ...)
|
|
609
|
+
publish_after_commit(UserCreatedEvent.new(...))
|
|
610
|
+
end
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
#### `log(level, message, data = {})`
|
|
614
|
+
Log with service context:
|
|
615
|
+
|
|
616
|
+
```ruby
|
|
617
|
+
log(:info, 'User created', user_id: user.id, email: user.email)
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
## Domain Exceptions
|
|
621
|
+
|
|
622
|
+
SmartDomain provides a hierarchy of domain exceptions for business rule violations:
|
|
623
|
+
|
|
624
|
+
```ruby
|
|
625
|
+
# Base exception
|
|
626
|
+
SmartDomain::Domain::Error
|
|
627
|
+
|
|
628
|
+
# Specific exceptions
|
|
629
|
+
SmartDomain::Domain::NotFoundError.new('User', user_id)
|
|
630
|
+
SmartDomain::Domain::AlreadyExistsError.new('User', 'email', email)
|
|
631
|
+
SmartDomain::Domain::BusinessRuleError.new('Cannot delete user with active orders')
|
|
632
|
+
SmartDomain::Domain::InvalidStateError.new('User', from: 'suspended', to: 'active')
|
|
633
|
+
SmartDomain::Domain::ValidationError.new('Validation failed', errors: { email: ['is required'] })
|
|
634
|
+
SmartDomain::Domain::UnauthorizedError.new('Not authorized')
|
|
635
|
+
SmartDomain::Domain::DependencyError.new('Redis')
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
All exceptions support structured error details:
|
|
639
|
+
|
|
640
|
+
```ruby
|
|
641
|
+
begin
|
|
642
|
+
service.create_user(params)
|
|
643
|
+
rescue SmartDomain::Domain::AlreadyExistsError => e
|
|
644
|
+
render json: e.to_h, status: :unprocessable_entity
|
|
645
|
+
# => {
|
|
646
|
+
# error: "SmartDomain::Domain::AlreadyExistsError",
|
|
647
|
+
# message: "User with email 'test@example.com' already exists",
|
|
648
|
+
# code: :already_exists,
|
|
649
|
+
# details: {
|
|
650
|
+
# entity_type: "User",
|
|
651
|
+
# attribute: "email",
|
|
652
|
+
# value: "test@example.com"
|
|
653
|
+
# }
|
|
654
|
+
# }
|
|
655
|
+
end
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
## Domain Policies (Authorization)
|
|
659
|
+
|
|
660
|
+
Domain policies encapsulate authorization logic (similar to Pundit):
|
|
661
|
+
|
|
662
|
+
```ruby
|
|
663
|
+
# app/policies/user_policy.rb
|
|
664
|
+
class UserPolicy < SmartDomain::Domain::Policy
|
|
665
|
+
def create?
|
|
666
|
+
user.admin? || user.manager?
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
def update?
|
|
670
|
+
user.admin? || owner?
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
def destroy?
|
|
674
|
+
user.admin? && record.id != user.id
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
def activate?
|
|
678
|
+
user.admin? && record.pending?
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
class Scope < Scope
|
|
682
|
+
def resolve
|
|
683
|
+
if user.admin?
|
|
684
|
+
scope.all
|
|
685
|
+
else
|
|
686
|
+
scope.where(organization_id: user.organization_id)
|
|
687
|
+
end
|
|
688
|
+
end
|
|
689
|
+
end
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
# Usage in service
|
|
693
|
+
policy = UserPolicy.new(current_user, user)
|
|
694
|
+
authorize!(policy, :update?) # Raises UnauthorizedError if false
|
|
695
|
+
|
|
696
|
+
# Usage in controller (for index)
|
|
697
|
+
@users = policy_scope(User.all, UserPolicy)
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
## ActiveRecord Integration
|
|
701
|
+
|
|
702
|
+
SmartDomain provides seamless integration with ActiveRecord for publishing domain events directly from models.
|
|
703
|
+
|
|
704
|
+
### Including in Models
|
|
705
|
+
|
|
706
|
+
```ruby
|
|
707
|
+
class User < ApplicationRecord
|
|
708
|
+
include SmartDomain::Integration::ActiveRecord
|
|
709
|
+
|
|
710
|
+
after_create :publish_created_event
|
|
711
|
+
after_update :publish_updated_event
|
|
712
|
+
after_destroy :publish_deleted_event
|
|
713
|
+
|
|
714
|
+
private
|
|
715
|
+
|
|
716
|
+
def publish_created_event
|
|
717
|
+
event = build_domain_event(UserCreatedEvent,
|
|
718
|
+
event_type: 'user.created',
|
|
719
|
+
user_id: id,
|
|
720
|
+
email: email
|
|
721
|
+
)
|
|
722
|
+
add_domain_event(event)
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
def publish_updated_event
|
|
726
|
+
return if saved_changes.empty?
|
|
727
|
+
|
|
728
|
+
changes = domain_event_changes
|
|
729
|
+
|
|
730
|
+
event = build_domain_event(UserUpdatedEvent,
|
|
731
|
+
event_type: 'user.updated',
|
|
732
|
+
user_id: id,
|
|
733
|
+
**changes # changed_fields, old_values, new_values
|
|
734
|
+
)
|
|
735
|
+
add_domain_event(event)
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
def publish_deleted_event
|
|
739
|
+
event = build_domain_event(UserDeletedEvent,
|
|
740
|
+
event_type: 'user.deleted',
|
|
741
|
+
user_id: id,
|
|
742
|
+
email: email
|
|
743
|
+
)
|
|
744
|
+
add_domain_event(event)
|
|
745
|
+
end
|
|
746
|
+
end
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
### How It Works
|
|
750
|
+
|
|
751
|
+
1. **Events are queued** during callbacks (after_create, after_update, etc.)
|
|
752
|
+
2. **Events are published** AFTER the database transaction commits
|
|
753
|
+
3. **If transaction rolls back**, events are automatically discarded
|
|
754
|
+
4. **Thread-safe** - Each model instance has its own event queue
|
|
755
|
+
|
|
756
|
+
### Helper Methods
|
|
757
|
+
|
|
758
|
+
#### `add_domain_event(event)`
|
|
759
|
+
Queue an event for publishing after commit:
|
|
760
|
+
|
|
761
|
+
```ruby
|
|
762
|
+
event = UserCreatedEvent.new(...)
|
|
763
|
+
add_domain_event(event)
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
#### `build_domain_event(event_class, attributes)`
|
|
767
|
+
Build an event with automatic field population:
|
|
768
|
+
|
|
769
|
+
```ruby
|
|
770
|
+
# Automatically fills: aggregate_id, aggregate_type, organization_id
|
|
771
|
+
event = build_domain_event(UserCreatedEvent,
|
|
772
|
+
event_type: 'user.created',
|
|
773
|
+
user_id: id,
|
|
774
|
+
email: email
|
|
775
|
+
)
|
|
776
|
+
```
|
|
777
|
+
|
|
778
|
+
#### `domain_event_changes`
|
|
779
|
+
Extract changes for ChangeTrackingMixin:
|
|
780
|
+
|
|
781
|
+
```ruby
|
|
782
|
+
user.update!(email: 'new@example.com')
|
|
783
|
+
|
|
784
|
+
changes = user.domain_event_changes
|
|
785
|
+
# => {
|
|
786
|
+
# changed_fields: ['email', 'updated_at'],
|
|
787
|
+
# old_values: { email: 'old@example.com', ... },
|
|
788
|
+
# new_values: { email: 'new@example.com', ... }
|
|
789
|
+
# }
|
|
790
|
+
```
|
|
791
|
+
|
|
792
|
+
### Transaction Safety
|
|
793
|
+
|
|
794
|
+
Events are **only published if the transaction succeeds**:
|
|
795
|
+
|
|
796
|
+
```ruby
|
|
797
|
+
User.transaction do
|
|
798
|
+
user = User.create!(email: 'test@example.com')
|
|
799
|
+
# Event queued, not published yet
|
|
800
|
+
|
|
801
|
+
raise ActiveRecord::Rollback # Transaction rolls back
|
|
802
|
+
# Event is discarded
|
|
803
|
+
end
|
|
804
|
+
# No event published!
|
|
805
|
+
|
|
806
|
+
User.transaction do
|
|
807
|
+
user = User.create!(email: 'test@example.com')
|
|
808
|
+
# Event queued
|
|
809
|
+
end # Transaction commits
|
|
810
|
+
# UserCreatedEvent published!
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
### Nested Transactions
|
|
814
|
+
|
|
815
|
+
Works correctly with nested transactions:
|
|
816
|
+
|
|
817
|
+
```ruby
|
|
818
|
+
User.transaction do
|
|
819
|
+
user = User.create!(email: 'test@example.com')
|
|
820
|
+
|
|
821
|
+
User.transaction(requires_new: true) do
|
|
822
|
+
profile = Profile.create!(user: user)
|
|
823
|
+
# Both events queued
|
|
824
|
+
end # Inner transaction commits
|
|
825
|
+
|
|
826
|
+
end # Outer transaction commits
|
|
827
|
+
# Both events published here
|
|
828
|
+
```
|
|
829
|
+
|
|
830
|
+
## Multi-Tenancy Support
|
|
831
|
+
|
|
832
|
+
SmartDomain provides built-in multi-tenancy support with thread-safe tenant context.
|
|
833
|
+
|
|
834
|
+
### Setting Up Multi-Tenancy
|
|
835
|
+
|
|
836
|
+
```ruby
|
|
837
|
+
# config/initializers/smart_domain.rb
|
|
838
|
+
SmartDomain.configure do |config|
|
|
839
|
+
config.multi_tenancy_enabled = true
|
|
840
|
+
config.tenant_key = :organization_id # Your tenant column name
|
|
841
|
+
end
|
|
842
|
+
```
|
|
843
|
+
|
|
844
|
+
### Controller Integration
|
|
845
|
+
|
|
846
|
+
```ruby
|
|
847
|
+
class ApplicationController < ActionController::Base
|
|
848
|
+
around_action :set_current_tenant
|
|
849
|
+
|
|
850
|
+
private
|
|
851
|
+
|
|
852
|
+
def set_current_tenant
|
|
853
|
+
SmartDomain::Integration::TenantContext.with_tenant(current_organization.id) do
|
|
854
|
+
yield
|
|
855
|
+
end
|
|
856
|
+
end
|
|
857
|
+
end
|
|
858
|
+
```
|
|
859
|
+
|
|
860
|
+
### Automatic Tenant Assignment
|
|
861
|
+
|
|
862
|
+
```ruby
|
|
863
|
+
class User < ApplicationRecord
|
|
864
|
+
include SmartDomain::Integration::TenantScoped
|
|
865
|
+
|
|
866
|
+
# organization_id will be automatically set from TenantContext.current
|
|
867
|
+
end
|
|
868
|
+
|
|
869
|
+
# In controller
|
|
870
|
+
SmartDomain::Integration::TenantContext.with_tenant('org-123') do
|
|
871
|
+
user = User.create!(email: 'test@example.com')
|
|
872
|
+
# user.organization_id => 'org-123'
|
|
873
|
+
end
|
|
874
|
+
```
|
|
875
|
+
|
|
876
|
+
### Tenant Context API
|
|
877
|
+
|
|
878
|
+
```ruby
|
|
879
|
+
# Get current tenant
|
|
880
|
+
tenant_id = SmartDomain::Integration::TenantContext.current
|
|
881
|
+
|
|
882
|
+
# Set current tenant
|
|
883
|
+
SmartDomain::Integration::TenantContext.current = 'org-123'
|
|
884
|
+
|
|
885
|
+
# Execute within tenant context
|
|
886
|
+
SmartDomain::Integration::TenantContext.with_tenant('org-123') do
|
|
887
|
+
# All operations use org-123 as tenant
|
|
888
|
+
end
|
|
889
|
+
|
|
890
|
+
# Check if tenant is set
|
|
891
|
+
SmartDomain::Integration::TenantContext.tenant_set? # => true/false
|
|
892
|
+
|
|
893
|
+
# Clear tenant
|
|
894
|
+
SmartDomain::Integration::TenantContext.clear!
|
|
895
|
+
```
|
|
896
|
+
|
|
897
|
+
## Complete Example
|
|
898
|
+
|
|
899
|
+
Here's a complete example combining all features:
|
|
900
|
+
|
|
901
|
+
```ruby
|
|
902
|
+
# app/models/user.rb
|
|
903
|
+
class User < ApplicationRecord
|
|
904
|
+
include SmartDomain::Integration::ActiveRecord
|
|
905
|
+
include SmartDomain::Integration::TenantScoped
|
|
906
|
+
|
|
907
|
+
validates :email, presence: true, uniqueness: true
|
|
908
|
+
|
|
909
|
+
after_create :publish_created_event
|
|
910
|
+
after_update :publish_updated_event
|
|
911
|
+
|
|
912
|
+
private
|
|
913
|
+
|
|
914
|
+
def publish_created_event
|
|
915
|
+
event = build_domain_event(UserCreatedEvent,
|
|
916
|
+
event_type: 'user.created',
|
|
917
|
+
user_id: id,
|
|
918
|
+
email: email
|
|
919
|
+
)
|
|
920
|
+
add_domain_event(event)
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
def publish_updated_event
|
|
924
|
+
return if saved_changes.empty?
|
|
925
|
+
|
|
926
|
+
event = build_domain_event(UserUpdatedEvent,
|
|
927
|
+
event_type: 'user.updated',
|
|
928
|
+
user_id: id,
|
|
929
|
+
**domain_event_changes
|
|
930
|
+
)
|
|
931
|
+
add_domain_event(event)
|
|
932
|
+
end
|
|
933
|
+
end
|
|
934
|
+
|
|
935
|
+
# app/events/user_created_event.rb
|
|
936
|
+
class UserCreatedEvent < SmartDomain::Event::Base
|
|
937
|
+
include SmartDomain::Event::ActorMixin
|
|
938
|
+
|
|
939
|
+
attribute :user_id, :string
|
|
940
|
+
attribute :email, :string
|
|
941
|
+
|
|
942
|
+
validates :user_id, :email, presence: true
|
|
943
|
+
end
|
|
944
|
+
|
|
945
|
+
# app/events/user_updated_event.rb
|
|
946
|
+
class UserUpdatedEvent < SmartDomain::Event::Base
|
|
947
|
+
include SmartDomain::Event::ActorMixin
|
|
948
|
+
include SmartDomain::Event::ChangeTrackingMixin
|
|
949
|
+
|
|
950
|
+
attribute :user_id, :string
|
|
951
|
+
|
|
952
|
+
validates :user_id, presence: true
|
|
953
|
+
end
|
|
954
|
+
|
|
955
|
+
# app/services/user_service.rb
|
|
956
|
+
class UserService < SmartDomain::Domain::Service
|
|
957
|
+
def create_user(attributes)
|
|
958
|
+
# Validation
|
|
959
|
+
if User.exists?(email: attributes[:email])
|
|
960
|
+
raise SmartDomain::Domain::AlreadyExistsError.new('User', 'email', attributes[:email])
|
|
961
|
+
end
|
|
962
|
+
|
|
963
|
+
# Create user (events published automatically)
|
|
964
|
+
User.create!(attributes)
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
def update_user(user_id, attributes)
|
|
968
|
+
user = User.find(user_id)
|
|
969
|
+
|
|
970
|
+
# Authorization
|
|
971
|
+
policy = UserPolicy.new(current_user, user)
|
|
972
|
+
authorize!(policy, :update?)
|
|
973
|
+
|
|
974
|
+
# Update user (events published automatically)
|
|
975
|
+
user.update!(attributes)
|
|
976
|
+
user
|
|
977
|
+
end
|
|
978
|
+
end
|
|
979
|
+
|
|
980
|
+
# config/initializers/smart_domain.rb
|
|
981
|
+
SmartDomain.configure do |config|
|
|
982
|
+
config.event_bus_adapter = :memory
|
|
983
|
+
config.audit_table_enabled = true
|
|
984
|
+
config.multi_tenancy_enabled = true
|
|
985
|
+
config.tenant_key = :organization_id
|
|
986
|
+
config.logger = Rails.logger
|
|
987
|
+
end
|
|
988
|
+
|
|
989
|
+
# Setup event handlers
|
|
990
|
+
SmartDomain::Event::Registration.register_standard_handlers(
|
|
991
|
+
domain: 'user',
|
|
992
|
+
events: %w[created updated deleted],
|
|
993
|
+
include_audit: true,
|
|
994
|
+
include_metrics: true
|
|
995
|
+
)
|
|
996
|
+
|
|
997
|
+
# app/controllers/users_controller.rb
|
|
998
|
+
class UsersController < ApplicationController
|
|
999
|
+
around_action :set_current_tenant
|
|
1000
|
+
|
|
1001
|
+
def create
|
|
1002
|
+
service = UserService.new(
|
|
1003
|
+
current_user: current_user,
|
|
1004
|
+
organization_id: current_organization.id
|
|
1005
|
+
)
|
|
1006
|
+
|
|
1007
|
+
@user = service.create_user(user_params)
|
|
1008
|
+
redirect_to @user, notice: 'User created successfully'
|
|
1009
|
+
rescue SmartDomain::Domain::AlreadyExistsError => e
|
|
1010
|
+
flash[:error] = e.message
|
|
1011
|
+
render :new
|
|
1012
|
+
end
|
|
1013
|
+
|
|
1014
|
+
private
|
|
1015
|
+
|
|
1016
|
+
def set_current_tenant
|
|
1017
|
+
SmartDomain::Integration::TenantContext.with_tenant(current_organization.id) do
|
|
1018
|
+
yield
|
|
1019
|
+
end
|
|
1020
|
+
end
|
|
1021
|
+
end
|
|
1022
|
+
```
|
|
1023
|
+
|
|
1024
|
+
## Rails Generators
|
|
1025
|
+
|
|
1026
|
+
SmartDomain provides powerful generators to scaffold complete domains with one command.
|
|
1027
|
+
|
|
1028
|
+
### Install Generator
|
|
1029
|
+
|
|
1030
|
+
```bash
|
|
1031
|
+
rails generate smart_domain:install
|
|
1032
|
+
```
|
|
1033
|
+
|
|
1034
|
+
Creates the initial SmartDomain structure in your Rails app:
|
|
1035
|
+
- Configuration initializer
|
|
1036
|
+
- Base classes (ApplicationEvent, ApplicationPolicy, ApplicationService)
|
|
1037
|
+
- Directory structure (app/domains/, app/events/, app/handlers/, app/policies/)
|
|
1038
|
+
|
|
1039
|
+
### Domain Generator
|
|
1040
|
+
|
|
1041
|
+
```bash
|
|
1042
|
+
rails generate smart_domain:domain User
|
|
1043
|
+
```
|
|
1044
|
+
|
|
1045
|
+
Generates a complete domain structure:
|
|
1046
|
+
|
|
1047
|
+
```
|
|
1048
|
+
app/domains/user_management/
|
|
1049
|
+
user_service.rb # Business logic
|
|
1050
|
+
setup.rb # Event handler registration
|
|
1051
|
+
|
|
1052
|
+
app/events/
|
|
1053
|
+
user_created_event.rb # Created event
|
|
1054
|
+
user_updated_event.rb # Updated event (with ChangeTrackingMixin)
|
|
1055
|
+
user_deleted_event.rb # Deleted event
|
|
1056
|
+
|
|
1057
|
+
app/policies/
|
|
1058
|
+
user_policy.rb # Authorization rules
|
|
1059
|
+
```
|
|
1060
|
+
|
|
1061
|
+
**Generated Service** (`app/domains/user_management/user_service.rb`):
|
|
1062
|
+
- Complete CRUD operations (create, update, delete, list)
|
|
1063
|
+
- Authorization checks
|
|
1064
|
+
- Event publishing
|
|
1065
|
+
- Business rule validation examples
|
|
1066
|
+
- Policy scoping
|
|
1067
|
+
|
|
1068
|
+
**Generated Events** (`app/events/user_*_event.rb`):
|
|
1069
|
+
- UserCreatedEvent with ActorMixin
|
|
1070
|
+
- UserUpdatedEvent with ActorMixin + ChangeTrackingMixin
|
|
1071
|
+
- UserDeletedEvent with ActorMixin
|
|
1072
|
+
|
|
1073
|
+
**Generated Policy** (`app/policies/user_policy.rb`):
|
|
1074
|
+
- Authorization rules (index?, show?, create?, update?, destroy?)
|
|
1075
|
+
- Scope class for index queries
|
|
1076
|
+
- Helper methods (admin?, owner?, same_organization?)
|
|
1077
|
+
|
|
1078
|
+
**Generated Setup** (`app/domains/user_management/setup.rb`):
|
|
1079
|
+
- Automatic event handler registration
|
|
1080
|
+
- One-line registration for audit and metrics handlers
|
|
1081
|
+
- Examples for custom handlers
|
|
1082
|
+
|
|
1083
|
+
### Generator Options
|
|
1084
|
+
|
|
1085
|
+
```bash
|
|
1086
|
+
# Skip specific files
|
|
1087
|
+
rails generate smart_domain:domain Order --skip-service
|
|
1088
|
+
rails generate smart_domain:domain Product --skip-policy
|
|
1089
|
+
rails generate smart_domain:domain Invoice --skip-events
|
|
1090
|
+
|
|
1091
|
+
# Generate minimal domain
|
|
1092
|
+
rails generate smart_domain:domain Report --skip-service --skip-policy
|
|
1093
|
+
```
|
|
1094
|
+
|
|
1095
|
+
### Rake Tasks
|
|
1096
|
+
|
|
1097
|
+
```bash
|
|
1098
|
+
# List all registered domains
|
|
1099
|
+
rake smart_domain:domains
|
|
1100
|
+
|
|
1101
|
+
# Reload domain setups
|
|
1102
|
+
rake smart_domain:reload
|
|
1103
|
+
```
|
|
1104
|
+
|
|
1105
|
+
### Example Workflow
|
|
1106
|
+
|
|
1107
|
+
```bash
|
|
1108
|
+
# 1. Install SmartDomain
|
|
1109
|
+
rails generate smart_domain:install
|
|
1110
|
+
|
|
1111
|
+
# 2. Generate your first domain
|
|
1112
|
+
rails generate smart_domain:domain User
|
|
1113
|
+
|
|
1114
|
+
# 3. Create the User model
|
|
1115
|
+
rails generate model User email:string name:string organization:references
|
|
1116
|
+
|
|
1117
|
+
# 4. Add SmartDomain integration to the model
|
|
1118
|
+
# In app/models/user.rb:
|
|
1119
|
+
class User < ApplicationRecord
|
|
1120
|
+
include SmartDomain::Integration::ActiveRecord
|
|
1121
|
+
include SmartDomain::Integration::TenantScoped
|
|
1122
|
+
|
|
1123
|
+
after_create :publish_created_event
|
|
1124
|
+
after_update :publish_updated_event
|
|
1125
|
+
|
|
1126
|
+
private
|
|
1127
|
+
|
|
1128
|
+
def publish_created_event
|
|
1129
|
+
event = build_domain_event(UserCreatedEvent,
|
|
1130
|
+
event_type: 'user.created',
|
|
1131
|
+
user_id: id,
|
|
1132
|
+
email: email
|
|
1133
|
+
)
|
|
1134
|
+
add_domain_event(event)
|
|
1135
|
+
end
|
|
1136
|
+
|
|
1137
|
+
def publish_updated_event
|
|
1138
|
+
return if saved_changes.empty?
|
|
1139
|
+
|
|
1140
|
+
event = build_domain_event(UserUpdatedEvent,
|
|
1141
|
+
event_type: 'user.updated',
|
|
1142
|
+
user_id: id,
|
|
1143
|
+
**domain_event_changes
|
|
1144
|
+
)
|
|
1145
|
+
add_domain_event(event)
|
|
1146
|
+
end
|
|
1147
|
+
end
|
|
1148
|
+
|
|
1149
|
+
# 5. Use the service in your controller
|
|
1150
|
+
class UsersController < ApplicationController
|
|
1151
|
+
def create
|
|
1152
|
+
service = UserManagement::UserService.new(
|
|
1153
|
+
current_user: current_user,
|
|
1154
|
+
organization_id: current_organization.id
|
|
1155
|
+
)
|
|
1156
|
+
|
|
1157
|
+
@user = service.create_user(user_params)
|
|
1158
|
+
redirect_to @user, notice: 'User created successfully'
|
|
1159
|
+
rescue SmartDomain::Domain::AlreadyExistsError => e
|
|
1160
|
+
flash[:error] = e.message
|
|
1161
|
+
render :new
|
|
1162
|
+
end
|
|
1163
|
+
end
|
|
1164
|
+
|
|
1165
|
+
# 6. Restart Rails to load domain setup
|
|
1166
|
+
rails restart
|
|
1167
|
+
```
|
|
1168
|
+
|
|
1169
|
+
## Roadmap
|
|
1170
|
+
|
|
1171
|
+
- [x] Core event system (Base, Bus, Mixins, Handlers)
|
|
1172
|
+
- [x] Generic handlers (Audit, Metrics)
|
|
1173
|
+
- [x] Event registration helper (70% boilerplate reduction)
|
|
1174
|
+
- [x] Configuration DSL
|
|
1175
|
+
- [x] Domain service pattern
|
|
1176
|
+
- [x] Domain exceptions
|
|
1177
|
+
- [x] Domain policies (authorization)
|
|
1178
|
+
- [x] ActiveRecord integration (after_commit hooks)
|
|
1179
|
+
- [x] Multi-tenancy support
|
|
1180
|
+
- [x] Rails generators (`rails g smart_domain:domain User`)
|
|
1181
|
+
- [x] Railtie for automatic setup
|
|
1182
|
+
- [x] Rake tasks (list domains, reload setups)
|
|
1183
|
+
- [ ] Redis adapter
|
|
1184
|
+
- [ ] ActiveJob adapter
|
|
1185
|
+
- [ ] Example Rails application
|
|
1186
|
+
- [ ] Comprehensive test suite
|
|
1187
|
+
- [ ] Documentation site
|
|
1188
|
+
|
|
1189
|
+
## Contributing
|
|
1190
|
+
|
|
1191
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/rachid/smart_domain.
|
|
1192
|
+
|
|
1193
|
+
## License
|
|
1194
|
+
|
|
1195
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
1196
|
+
|
|
1197
|
+
## Acknowledgments
|
|
1198
|
+
|
|
1199
|
+
**Architecture designed and battle-tested by:**
|
|
1200
|
+
- Rachid Al Maach (@rachid)
|
|
1201
|
+
|
|
1202
|
+
**Influenced by:**
|
|
1203
|
+
- Domain-Driven Design (Eric Evans)
|
|
1204
|
+
- Event-Driven Architecture patterns
|
|
1205
|
+
- Rails Event Store
|
|
1206
|
+
- Healthcare platform architecture
|
|
1207
|
+
|
|
1208
|
+
## Support
|
|
1209
|
+
|
|
1210
|
+
For questions, issues, or feature requests, please:
|
|
1211
|
+
1. Check the documentation
|
|
1212
|
+
2. Search existing GitHub issues
|
|
1213
|
+
3. Create a new issue with detailed information
|
|
1214
|
+
|
|
1215
|
+
---
|
|
1216
|
+
|
|
1217
|
+
**Last Updated:** 2025-12-29
|
|
1218
|
+
**Version:** 0.1.0
|
|
1219
|
+
**Status:** Alpha - Core features implemented, generators and Rails integration coming soon
|