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,328 @@
|
|
|
1
|
+
# SmartDomain Example: Blog Application
|
|
2
|
+
|
|
3
|
+
This is a complete example Rails application demonstrating all the features of the **SmartDomain** gem.
|
|
4
|
+
|
|
5
|
+
## What This Example Demonstrates
|
|
6
|
+
|
|
7
|
+
This blog application showcases how SmartDomain brings Domain-Driven Design (DDD) and Event-Driven Architecture (EDA) patterns to Rails:
|
|
8
|
+
|
|
9
|
+
1. **Domain Services** - Business logic encapsulated in service objects
|
|
10
|
+
2. **Domain Events** - Immutable events with type safety and validation
|
|
11
|
+
3. **Event Bus** - Publish-subscribe pattern for event distribution
|
|
12
|
+
4. **Generic Handlers** - 70% boilerplate reduction with `register_standard_handlers`
|
|
13
|
+
5. **Custom Event Handlers** - Domain-specific handlers for side effects
|
|
14
|
+
6. **Event Mixins** - Reusable event attributes (Actor, ChangeTracking, etc.)
|
|
15
|
+
7. **Domain Policies** - Pundit-style authorization
|
|
16
|
+
8. **Multi-tenancy** - Organization-based data isolation
|
|
17
|
+
9. **ActiveRecord Integration** - Transaction-safe event publishing
|
|
18
|
+
|
|
19
|
+
## Application Structure
|
|
20
|
+
|
|
21
|
+
This example uses a **domain-centric structure** where all domain-specific files (models, events, handlers, policies) are grouped within each domain directory. This follows Domain-Driven Design (DDD) best practices and the Aeyes architecture pattern.
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
app/
|
|
25
|
+
├── domains/
|
|
26
|
+
│ ├── user_management/ # User domain (bounded context)
|
|
27
|
+
│ │ ├── models/
|
|
28
|
+
│ │ │ └── user.rb # User model with SmartDomain integration
|
|
29
|
+
│ │ ├── events/
|
|
30
|
+
│ │ │ ├── user_created_event.rb # User created event
|
|
31
|
+
│ │ │ ├── user_updated_event.rb # User updated event
|
|
32
|
+
│ │ │ └── user_deleted_event.rb # User deleted event
|
|
33
|
+
│ │ ├── handlers/
|
|
34
|
+
│ │ │ └── user_welcome_handler.rb # Custom: Send welcome emails
|
|
35
|
+
│ │ ├── policies/
|
|
36
|
+
│ │ │ └── user_policy.rb # User authorization
|
|
37
|
+
│ │ ├── user_service.rb # Business logic for users
|
|
38
|
+
│ │ └── setup.rb # Event handler registration
|
|
39
|
+
│ │
|
|
40
|
+
│ └── post_management/ # Post domain (bounded context)
|
|
41
|
+
│ ├── models/
|
|
42
|
+
│ │ └── post.rb # Post model with SmartDomain integration
|
|
43
|
+
│ ├── events/
|
|
44
|
+
│ │ ├── post_created_event.rb # Post created event
|
|
45
|
+
│ │ ├── post_updated_event.rb # Post updated event
|
|
46
|
+
│ │ └── post_deleted_event.rb # Post deleted event
|
|
47
|
+
│ ├── handlers/
|
|
48
|
+
│ │ └── post_notification_handler.rb # Custom: Notify on publish
|
|
49
|
+
│ ├── policies/
|
|
50
|
+
│ │ └── post_policy.rb # Post authorization
|
|
51
|
+
│ ├── post_service.rb # Business logic for posts
|
|
52
|
+
│ └── setup.rb # Event handler registration
|
|
53
|
+
│
|
|
54
|
+
├── events/
|
|
55
|
+
│ └── application_event.rb # Base event class (shared)
|
|
56
|
+
├── handlers/
|
|
57
|
+
│ └── .keep
|
|
58
|
+
├── policies/
|
|
59
|
+
│ └── application_policy.rb # Base policy class (shared)
|
|
60
|
+
└── models/
|
|
61
|
+
├── organization.rb # Multi-tenant organization (shared)
|
|
62
|
+
└── application_record.rb # Base model class (shared)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**Key Design Decisions:**
|
|
66
|
+
|
|
67
|
+
1. **Domain-Centric Organization**: All domain-specific code lives within `app/domains/{domain}/` subdirectories
|
|
68
|
+
2. **Shared Base Classes**: Base classes like `ApplicationEvent` and `ApplicationPolicy` remain at the top level since they're shared across domains
|
|
69
|
+
3. **Bounded Contexts**: Each domain is a self-contained bounded context with its own models, events, handlers, and policies
|
|
70
|
+
4. **Autoloading Configuration**: Rails is configured to autoload from domain subdirectories (see `config/application.rb`)
|
|
71
|
+
|
|
72
|
+
## Setup Instructions
|
|
73
|
+
|
|
74
|
+
### 1. Install Dependencies
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
cd examples/blog_app
|
|
78
|
+
bundle install
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### 2. Setup Database
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
rails db:create db:migrate
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### 3. Run the Demo Script
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
rails runner lib/demo.rb
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
This will run a comprehensive demonstration showing:
|
|
94
|
+
- Creating an organization (multi-tenancy)
|
|
95
|
+
- Creating users via domain services
|
|
96
|
+
- Publishing domain events
|
|
97
|
+
- Generic and custom event handlers
|
|
98
|
+
- Creating and publishing blog posts
|
|
99
|
+
- Authorization with policies
|
|
100
|
+
- Multi-tenant data isolation
|
|
101
|
+
|
|
102
|
+
## Key Features Demonstrated
|
|
103
|
+
|
|
104
|
+
### 1. Domain Services
|
|
105
|
+
|
|
106
|
+
Services encapsulate business logic and publish domain events:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
service = UserManagement::UserService.new(
|
|
110
|
+
current_user: admin,
|
|
111
|
+
organization_id: org.id
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
user = service.create(User, {
|
|
115
|
+
email: "john@example.com",
|
|
116
|
+
name: "John Doe",
|
|
117
|
+
role: "admin"
|
|
118
|
+
})
|
|
119
|
+
# Automatically publishes UserCreatedEvent
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### 2. Event Publishing with Mixins
|
|
123
|
+
|
|
124
|
+
Events use mixins for common attributes:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
event = UserCreatedEvent.new(
|
|
128
|
+
event_type: "user.created",
|
|
129
|
+
aggregate_id: user.id,
|
|
130
|
+
aggregate_type: "User",
|
|
131
|
+
organization_id: org.id,
|
|
132
|
+
actor_id: current_user.id, # From ActorMixin
|
|
133
|
+
actor_email: current_user.email, # From ActorMixin
|
|
134
|
+
user_id: user.id,
|
|
135
|
+
email: user.email
|
|
136
|
+
)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### 3. Generic Handlers (70% Boilerplate Reduction)
|
|
140
|
+
|
|
141
|
+
One line registers audit and metrics handlers for all events:
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
# In app/domains/user_management/setup.rb
|
|
145
|
+
SmartDomain::Event::Registration.register_standard_handlers(
|
|
146
|
+
domain: 'user',
|
|
147
|
+
events: %w[created updated deleted],
|
|
148
|
+
include_audit: true,
|
|
149
|
+
include_metrics: true
|
|
150
|
+
)
|
|
151
|
+
# Replaces ~50 lines of manual subscription code!
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### 4. Custom Event Handlers
|
|
155
|
+
|
|
156
|
+
Add domain-specific side effects:
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
class UserWelcomeHandler < SmartDomain::Event::Handler
|
|
160
|
+
def can_handle?(event_type)
|
|
161
|
+
event_type == "user.created"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def handle(event)
|
|
165
|
+
UserMailer.welcome_email(event.user_id).deliver_later
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Register in setup.rb
|
|
170
|
+
welcome_handler = UserWelcomeHandler.new
|
|
171
|
+
SmartDomain::Event.bus.subscribe('user.created', welcome_handler)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### 5. Change Tracking
|
|
175
|
+
|
|
176
|
+
Track what changed in update events:
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
event = PostUpdatedEvent.new(
|
|
180
|
+
event_type: "post.updated",
|
|
181
|
+
aggregate_id: post.id,
|
|
182
|
+
aggregate_type: "Post",
|
|
183
|
+
organization_id: org.id,
|
|
184
|
+
changed_fields: ["published", "published_at"],
|
|
185
|
+
old_values: { "published" => false, "published_at" => nil },
|
|
186
|
+
new_values: { "published" => true, "published_at" => Time.current }
|
|
187
|
+
)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### 6. Authorization with Policies
|
|
191
|
+
|
|
192
|
+
Pundit-style policies for domain authorization:
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
class PostPolicy < ApplicationPolicy
|
|
196
|
+
def update?
|
|
197
|
+
user_present? && (
|
|
198
|
+
user.role == "admin" ||
|
|
199
|
+
(user.role == "editor" && owner?)
|
|
200
|
+
)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def destroy?
|
|
204
|
+
user_present? && user.role == "admin"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
private
|
|
208
|
+
|
|
209
|
+
def owner?
|
|
210
|
+
record.user_id == user.id
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### 7. Multi-tenancy
|
|
216
|
+
|
|
217
|
+
Organization-based data isolation:
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
# Set tenant context
|
|
221
|
+
SmartDomain::Integration::TenantContext.with_tenant(org.id) do
|
|
222
|
+
# All queries are automatically scoped to this organization
|
|
223
|
+
users = User.all # Only returns users from org
|
|
224
|
+
posts = Post.all # Only returns posts from org
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Models use TenantScoped mixin
|
|
228
|
+
class User < ApplicationRecord
|
|
229
|
+
include SmartDomain::Integration::TenantScoped
|
|
230
|
+
tenant_key :organization_id
|
|
231
|
+
end
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### 8. ActiveRecord Integration
|
|
235
|
+
|
|
236
|
+
Transaction-safe event publishing:
|
|
237
|
+
|
|
238
|
+
```ruby
|
|
239
|
+
class Post < ApplicationRecord
|
|
240
|
+
include SmartDomain::Integration::ActiveRecord
|
|
241
|
+
|
|
242
|
+
after_commit :publish_created_event, on: :create
|
|
243
|
+
|
|
244
|
+
def publish_created_event
|
|
245
|
+
event = PostCreatedEvent.new(...)
|
|
246
|
+
add_domain_event(event)
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
# Events only publish after transaction commits!
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Event Flow Example
|
|
253
|
+
|
|
254
|
+
When a user is created through the service:
|
|
255
|
+
|
|
256
|
+
```
|
|
257
|
+
User.create! → UserService.create
|
|
258
|
+
↓
|
|
259
|
+
UserCreatedEvent published
|
|
260
|
+
↓
|
|
261
|
+
├─→ AuditHandler (logs to Rails.logger + audit table)
|
|
262
|
+
├─→ MetricsHandler (increments metrics)
|
|
263
|
+
└─→ UserWelcomeHandler (sends welcome email)
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
All handlers run synchronously in the current thread with error isolation.
|
|
267
|
+
|
|
268
|
+
## Running Tests
|
|
269
|
+
|
|
270
|
+
The example app doesn't include tests, but demonstrates patterns you can test:
|
|
271
|
+
|
|
272
|
+
```ruby
|
|
273
|
+
# Test event publishing
|
|
274
|
+
expect {
|
|
275
|
+
service.create(User, attributes)
|
|
276
|
+
}.to publish_event(UserCreatedEvent)
|
|
277
|
+
|
|
278
|
+
# Test handlers
|
|
279
|
+
handler = UserWelcomeHandler.new
|
|
280
|
+
event = UserCreatedEvent.new(...)
|
|
281
|
+
expect { handler.handle(event) }.to send_email
|
|
282
|
+
|
|
283
|
+
# Test policies
|
|
284
|
+
policy = PostPolicy.new(user, post)
|
|
285
|
+
expect(policy.update?).to be true
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
## Viewing Logs
|
|
289
|
+
|
|
290
|
+
All events and handler executions are logged. Run the demo and watch the logs:
|
|
291
|
+
|
|
292
|
+
```bash
|
|
293
|
+
rails runner lib/demo.rb | grep -E '\[SmartDomain|User|Post'
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
You'll see:
|
|
297
|
+
- Event publications
|
|
298
|
+
- Handler subscriptions
|
|
299
|
+
- Audit logs
|
|
300
|
+
- Metrics increments
|
|
301
|
+
- Custom handler executions
|
|
302
|
+
|
|
303
|
+
## Next Steps
|
|
304
|
+
|
|
305
|
+
1. **Explore the generated files**: Look at the domain services, events, and policies
|
|
306
|
+
2. **Customize the business logic**: Add validation, workflows, or complex rules
|
|
307
|
+
3. **Add more handlers**: Create handlers for emails, notifications, webhooks
|
|
308
|
+
4. **Try multi-tenancy**: Create multiple organizations and see data isolation
|
|
309
|
+
5. **Add authorization**: Use policies in controllers to enforce permissions
|
|
310
|
+
6. **Extend events**: Add more mixins like SecurityContextMixin or ReasonMixin
|
|
311
|
+
|
|
312
|
+
## Learn More
|
|
313
|
+
|
|
314
|
+
- **Gem Documentation**: ../../README.md
|
|
315
|
+
- **SmartDomain GitHub**: https://github.com/rachid/smart_domain
|
|
316
|
+
- **Configuration**: config/initializers/smart_domain.rb
|
|
317
|
+
|
|
318
|
+
## Key Takeaways
|
|
319
|
+
|
|
320
|
+
1. **Services own business logic**, controllers delegate
|
|
321
|
+
2. **Events are immutable** and published after transaction commits
|
|
322
|
+
3. **Generic handlers** eliminate 70% of boilerplate code
|
|
323
|
+
4. **Custom handlers** enable domain-specific side effects
|
|
324
|
+
5. **Policies centralize** authorization logic
|
|
325
|
+
6. **Multi-tenancy** is automatic with TenantContext
|
|
326
|
+
7. **Everything is type-safe** and validated
|
|
327
|
+
|
|
328
|
+
This architecture scales from small apps to large platforms while maintaining clean, maintainable code.
|
|
File without changes
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This is a manifest file that'll be compiled into application.css.
|
|
3
|
+
*
|
|
4
|
+
* With Propshaft, assets are served efficiently without preprocessing steps. You can still include
|
|
5
|
+
* application-wide styles in this file, but keep in mind that CSS precedence will follow the standard
|
|
6
|
+
* cascading order, meaning styles declared later in the document or manifest will override earlier ones,
|
|
7
|
+
* depending on specificity.
|
|
8
|
+
*
|
|
9
|
+
* Consider organizing styles into separate files for maintainability.
|
|
10
|
+
*/
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Api
|
|
4
|
+
# Base API controller with common functionality
|
|
5
|
+
class BaseController < ApplicationController
|
|
6
|
+
# Skip CSRF for API requests
|
|
7
|
+
skip_before_action :verify_authenticity_token
|
|
8
|
+
|
|
9
|
+
# Simple authentication simulation
|
|
10
|
+
# In a real app, this would use JWT, sessions, etc.
|
|
11
|
+
before_action :set_current_organization
|
|
12
|
+
before_action :set_current_user
|
|
13
|
+
|
|
14
|
+
rescue_from ActiveRecord::RecordNotFound, with: :not_found
|
|
15
|
+
rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity
|
|
16
|
+
rescue_from SmartDomain::Domain::Exceptions::UnauthorizedError, with: :forbidden
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def set_current_organization
|
|
21
|
+
# In real app: decode from JWT, session, subdomain, etc.
|
|
22
|
+
# For demo: use header or first organization
|
|
23
|
+
org_id = request.headers['X-Organization-Id']
|
|
24
|
+
@current_organization = if org_id
|
|
25
|
+
Organization.find(org_id)
|
|
26
|
+
else
|
|
27
|
+
Organization.first || Organization.create!(name: "Demo Organization")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def set_current_user
|
|
32
|
+
# In real app: decode from JWT token
|
|
33
|
+
# For demo: use header or create demo user
|
|
34
|
+
user_id = request.headers['X-User-Id']
|
|
35
|
+
@current_user = if user_id
|
|
36
|
+
User.find(user_id)
|
|
37
|
+
else
|
|
38
|
+
# Create a demo admin user if none exists
|
|
39
|
+
@current_organization.users.first || @current_organization.users.create!(
|
|
40
|
+
email: "admin@example.com",
|
|
41
|
+
name: "Demo Admin",
|
|
42
|
+
role: "admin"
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
attr_reader :current_user, :current_organization
|
|
48
|
+
|
|
49
|
+
def not_found(exception)
|
|
50
|
+
render json: { error: exception.message }, status: :not_found
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def unprocessable_entity(exception)
|
|
54
|
+
render json: { error: exception.message, details: exception.record.errors }, status: :unprocessable_entity
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def forbidden(exception)
|
|
58
|
+
render json: { error: exception.message }, status: :forbidden
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Api
|
|
4
|
+
module V1
|
|
5
|
+
# Posts API Controller
|
|
6
|
+
#
|
|
7
|
+
# Demonstrates:
|
|
8
|
+
# - Thin controller delegating to PostService (domain layer)
|
|
9
|
+
# - Policy authorization using PostPolicy
|
|
10
|
+
# - Custom actions (publish/unpublish) triggering events
|
|
11
|
+
# - Change tracking in update events
|
|
12
|
+
class PostsController < Api::BaseController
|
|
13
|
+
before_action :set_post, only: [:show, :update, :destroy, :publish, :unpublish]
|
|
14
|
+
|
|
15
|
+
# GET /api/v1/posts
|
|
16
|
+
def index
|
|
17
|
+
@posts = SmartDomain::Integration::TenantContext.with_tenant(current_organization.id) do
|
|
18
|
+
# Optional filter by published status
|
|
19
|
+
scope = Post.includes(:user)
|
|
20
|
+
scope = params[:published] == 'true' ? scope.published : scope if params[:published].present?
|
|
21
|
+
scope.all
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
render json: @posts, include: :user
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# GET /api/v1/posts/:id
|
|
28
|
+
def show
|
|
29
|
+
authorize @post, :show?
|
|
30
|
+
render json: @post, include: :user
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# POST /api/v1/posts
|
|
34
|
+
def create
|
|
35
|
+
service = PostManagement::PostService.new(
|
|
36
|
+
current_user: current_user,
|
|
37
|
+
organization_id: current_organization.id
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Service handles:
|
|
41
|
+
# 1. Validation
|
|
42
|
+
# 2. Creating the post
|
|
43
|
+
# 3. Publishing PostCreatedEvent
|
|
44
|
+
# 4. Triggering audit and metrics handlers
|
|
45
|
+
@post = service.create_post(post_params.merge(user_id: current_user.id))
|
|
46
|
+
|
|
47
|
+
render json: @post, status: :created
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# PATCH/PUT /api/v1/posts/:id
|
|
51
|
+
def update
|
|
52
|
+
authorize @post, :update?
|
|
53
|
+
|
|
54
|
+
service = PostManagement::PostService.new(
|
|
55
|
+
current_user: current_user,
|
|
56
|
+
organization_id: current_organization.id
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Service publishes PostUpdatedEvent with change tracking
|
|
60
|
+
@post = service.update_post(@post, post_params)
|
|
61
|
+
|
|
62
|
+
render json: @post
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# DELETE /api/v1/posts/:id
|
|
66
|
+
def destroy
|
|
67
|
+
authorize @post, :destroy?
|
|
68
|
+
|
|
69
|
+
service = PostManagement::PostService.new(
|
|
70
|
+
current_user: current_user,
|
|
71
|
+
organization_id: current_organization.id
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Service publishes PostDeletedEvent
|
|
75
|
+
service.delete_post(@post)
|
|
76
|
+
|
|
77
|
+
head :no_content
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# POST /api/v1/posts/:id/publish
|
|
81
|
+
# Custom action demonstrating change tracking events
|
|
82
|
+
def publish
|
|
83
|
+
authorize @post, :update?
|
|
84
|
+
|
|
85
|
+
# Track old state for change tracking
|
|
86
|
+
old_published = @post.published
|
|
87
|
+
old_published_at = @post.published_at
|
|
88
|
+
|
|
89
|
+
@post.publish!
|
|
90
|
+
|
|
91
|
+
# Publish event with change tracking
|
|
92
|
+
event = PostUpdatedEvent.new(
|
|
93
|
+
event_type: "post.updated",
|
|
94
|
+
aggregate_id: @post.id.to_s,
|
|
95
|
+
aggregate_type: "Post",
|
|
96
|
+
organization_id: current_organization.id.to_s,
|
|
97
|
+
actor_id: current_user.id.to_s,
|
|
98
|
+
actor_email: current_user.email,
|
|
99
|
+
post_id: @post.id.to_s,
|
|
100
|
+
changed_fields: ["published", "published_at"],
|
|
101
|
+
old_values: { "published" => old_published, "published_at" => old_published_at },
|
|
102
|
+
new_values: { "published" => true, "published_at" => @post.published_at.iso8601 }
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
SmartDomain::Event.bus.publish(event)
|
|
106
|
+
|
|
107
|
+
render json: @post
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# POST /api/v1/posts/:id/unpublish
|
|
111
|
+
def unpublish
|
|
112
|
+
authorize @post, :update?
|
|
113
|
+
|
|
114
|
+
old_published = @post.published
|
|
115
|
+
old_published_at = @post.published_at
|
|
116
|
+
|
|
117
|
+
@post.unpublish!
|
|
118
|
+
|
|
119
|
+
# Publish event with change tracking
|
|
120
|
+
event = PostUpdatedEvent.new(
|
|
121
|
+
event_type: "post.updated",
|
|
122
|
+
aggregate_id: @post.id.to_s,
|
|
123
|
+
aggregate_type: "Post",
|
|
124
|
+
organization_id: current_organization.id.to_s,
|
|
125
|
+
actor_id: current_user.id.to_s,
|
|
126
|
+
actor_email: current_user.email,
|
|
127
|
+
post_id: @post.id.to_s,
|
|
128
|
+
changed_fields: ["published", "published_at"],
|
|
129
|
+
old_values: { "published" => old_published, "published_at" => old_published_at&.iso8601 },
|
|
130
|
+
new_values: { "published" => false, "published_at" => nil }
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
SmartDomain::Event.bus.publish(event)
|
|
134
|
+
|
|
135
|
+
render json: @post
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
def set_post
|
|
141
|
+
@post = SmartDomain::Integration::TenantContext.with_tenant(current_organization.id) do
|
|
142
|
+
Post.find(params[:id])
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def post_params
|
|
147
|
+
params.require(:post).permit(:title, :body, :published)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def authorize(post, action)
|
|
151
|
+
policy = PostPolicy.new(current_user, post)
|
|
152
|
+
unless policy.public_send(action)
|
|
153
|
+
raise SmartDomain::Domain::Exceptions::UnauthorizedError, "Not authorized to #{action} this post"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Api
|
|
4
|
+
module V1
|
|
5
|
+
# Users API Controller
|
|
6
|
+
#
|
|
7
|
+
# Demonstrates:
|
|
8
|
+
# - Thin controller delegating to UserService (domain layer)
|
|
9
|
+
# - Policy authorization using UserPolicy
|
|
10
|
+
# - Multi-tenancy with current_organization
|
|
11
|
+
# - Events automatically published via service layer
|
|
12
|
+
class UsersController < Api::BaseController
|
|
13
|
+
before_action :set_user, only: [:show, :update, :destroy]
|
|
14
|
+
|
|
15
|
+
# GET /api/v1/users
|
|
16
|
+
def index
|
|
17
|
+
# Automatically scoped to current_organization via TenantScoped
|
|
18
|
+
@users = SmartDomain::Integration::TenantContext.with_tenant(current_organization.id) do
|
|
19
|
+
User.all
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
render json: @users
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# GET /api/v1/users/:id
|
|
26
|
+
def show
|
|
27
|
+
authorize @user, :show?
|
|
28
|
+
render json: @user
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# POST /api/v1/users
|
|
32
|
+
def create
|
|
33
|
+
service = UserManagement::UserService.new(
|
|
34
|
+
current_user: current_user,
|
|
35
|
+
organization_id: current_organization.id
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Service handles:
|
|
39
|
+
# 1. Business logic validation
|
|
40
|
+
# 2. Creating the user
|
|
41
|
+
# 3. Publishing UserCreatedEvent
|
|
42
|
+
# 4. Triggering handlers (audit, metrics, welcome email)
|
|
43
|
+
@user = service.create_user(user_params)
|
|
44
|
+
|
|
45
|
+
render json: @user, status: :created
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# PATCH/PUT /api/v1/users/:id
|
|
49
|
+
def update
|
|
50
|
+
authorize @user, :update?
|
|
51
|
+
|
|
52
|
+
service = UserManagement::UserService.new(
|
|
53
|
+
current_user: current_user,
|
|
54
|
+
organization_id: current_organization.id
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Service publishes UserUpdatedEvent with change tracking
|
|
58
|
+
@user = service.update_user(@user, user_params)
|
|
59
|
+
|
|
60
|
+
render json: @user
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# DELETE /api/v1/users/:id
|
|
64
|
+
def destroy
|
|
65
|
+
authorize @user, :destroy?
|
|
66
|
+
|
|
67
|
+
service = UserManagement::UserService.new(
|
|
68
|
+
current_user: current_user,
|
|
69
|
+
organization_id: current_organization.id
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Service publishes UserDeletedEvent
|
|
73
|
+
service.delete_user(@user)
|
|
74
|
+
|
|
75
|
+
head :no_content
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def set_user
|
|
81
|
+
@user = SmartDomain::Integration::TenantContext.with_tenant(current_organization.id) do
|
|
82
|
+
User.find(params[:id])
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def user_params
|
|
87
|
+
params.require(:user).permit(:email, :name, :role)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def authorize(user, action)
|
|
91
|
+
policy = UserPolicy.new(current_user, user)
|
|
92
|
+
unless policy.public_send(action)
|
|
93
|
+
raise SmartDomain::Domain::Exceptions::UnauthorizedError, "Not authorized to #{action} this user"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
class ApplicationController < ActionController::Base
|
|
2
|
+
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
|
3
|
+
allow_browser versions: :modern
|
|
4
|
+
|
|
5
|
+
# Changes to the importmap will invalidate the etag for HTML responses
|
|
6
|
+
stale_when_importmap_changes
|
|
7
|
+
end
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmartDomain
|
|
4
|
+
module Domain
|
|
5
|
+
module Exceptions
|
|
6
|
+
# Base domain exception
|
|
7
|
+
class DomainError < StandardError; end
|
|
8
|
+
|
|
9
|
+
# Authorization error
|
|
10
|
+
class UnauthorizedError < DomainError; end
|
|
11
|
+
|
|
12
|
+
# Validation error
|
|
13
|
+
class ValidationError < DomainError; end
|
|
14
|
+
|
|
15
|
+
# Not found error
|
|
16
|
+
class NotFoundError < DomainError; end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|