rivulet-rb 0.1.7 → 0.2.1

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.
@@ -0,0 +1,651 @@
1
+ # Rivulet Architecture & Design
2
+
3
+ ## Design Philosophy
4
+
5
+ Rivulet is designed for **predictability** and **explicit error handling**. By leveraging Railway Oriented Programming (ROP), the framework eliminates hidden control flows commonly found in traditional web frameworks. Every step of a request's lifecycle is an explicit transformation, making the system easy to trace, test, and extend.
6
+
7
+ ### Put More Effort into Writing than Reading
8
+
9
+ One of the core principles of Rivulet is making business domains and workflows easy to understand. This is achieved through architectural conventions and design restrictions that encourage self-documenting code.
10
+
11
+ Operations, steps, contracts, routes, and containers should communicate not only execution flow but also intent.
12
+
13
+ ### Design for Reuse, Not for Abstraction
14
+
15
+ Rivulet does not discourage copy-paste when it improves clarity or avoids premature abstraction. Reuse should emerge from real patterns rather than from attempts to eliminate every duplication.
16
+
17
+ At the same time, developers are encouraged to design components so they can be easily extracted, shared, and connected as plug-and-play building blocks when reuse becomes valuable.
18
+
19
+ Operations, steps, shared components, and utilities should favor explicit interfaces and minimal coupling, making them easy to move between domains, applications, or shared libraries without significant modification.
20
+
21
+ The goal is not to maximize abstraction, but to make reuse straightforward when it naturally occurs.
22
+
23
+ ### Be Explicit, Less Magic
24
+
25
+ There are no hidden variables, implicit context objects, or parent classes that redefine behavior behind the scenes.
26
+
27
+ All dependencies, parameters, and execution context are passed explicitly through the application flow.
28
+
29
+ ## Architecture
30
+
31
+ Rivulet separates transport concerns from domain concerns through two independent execution layers.
32
+
33
+ ### Handlers
34
+
35
+ Handlers represent the transport layer.
36
+
37
+ They are responsible for:
38
+
39
+ * Processing incoming requests.
40
+ * Preparing execution context.
41
+ * Delegating work to services in the domain layer.
42
+ * Producing HTTP responses.
43
+
44
+ ### Services
45
+
46
+ Services represent the domain layer.
47
+
48
+ They are responsible for:
49
+
50
+ * Executing business use cases.
51
+ * Coordinating domain-specific workflows.
52
+ * Managing resource lifecycles.
53
+ * Remaining independent from HTTP and transport concerns.
54
+
55
+ Services can be executed from handlers, background jobs, CLI commands, message consumers, or any other runtime environment.
56
+
57
+ ### Shared Architectural Model
58
+
59
+ Services are organized around domains rather than technical layers.
60
+
61
+ ```text
62
+ services/
63
+ ├── shared/
64
+ └── users/
65
+ ├── operations/
66
+ ├── steps/
67
+ └── utils/
68
+ ```
69
+
70
+ Each service domain contains its own operations, steps, and utilities, while reusable domain-agnostic components live under `services/shared`.
71
+
72
+ Handlers follow the same domain-oriented approach:
73
+
74
+ ```text
75
+ handlers/
76
+ ├── shared/
77
+ └── users/
78
+ ├── operations/
79
+ ├── steps/
80
+ └── utils/
81
+ ```
82
+
83
+ Handler domains contain transport-specific operations, steps, and utilities, while reusable request-processing components live under `handlers/shared`.
84
+
85
+ Although handlers and services serve different responsibilities, they share the same architectural concepts:
86
+
87
+ * Operations define execution flow.
88
+ * Steps perform work.
89
+ * Shared components provide reusable pipeline behavior.
90
+ * Utils encapsulate implementation details.
91
+
92
+ This consistency allows developers to use the same programming model throughout the framework while maintaining a clear separation between transport and domain concerns.
93
+
94
+ ## Routing
95
+
96
+ Rivulet provides a lightweight routing DSL inspired by Rails.
97
+
98
+ Routes map incoming requests to handler operations.
99
+
100
+ Examples:
101
+
102
+ ```ruby
103
+ get '/users/:id', to: 'users#show'
104
+ post '/users', to: 'users#create'
105
+ ```
106
+
107
+ An alternative explicit syntax is also supported:
108
+
109
+ ```ruby
110
+ get :users, to: :users, action: :index
111
+ ```
112
+
113
+ ### URL Parameters
114
+
115
+ Routes support dynamic path segments through named parameters.
116
+
117
+ ```ruby
118
+ get '/users/:id', to: 'users#show'
119
+ ```
120
+
121
+ produces:
122
+
123
+ ```ruby
124
+ {
125
+ params: {
126
+ id: "123"
127
+ }
128
+ }
129
+ ```
130
+
131
+ Nested resources are supported as well:
132
+
133
+ ```ruby
134
+ get '/organizations/:org_id/users/:id', to: 'users#show'
135
+ ```
136
+
137
+ which produces:
138
+
139
+ ```ruby
140
+ {
141
+ params: {
142
+ org_id: "1",
143
+ id: "42"
144
+ }
145
+ }
146
+ ```
147
+
148
+ Path parameters become part of the request input and are available throughout the execution pipeline.
149
+
150
+ ### Routing Philosophy
151
+
152
+ Routing belongs to the transport layer and should not influence domain behavior.
153
+
154
+ Its responsibility is to expose use cases through HTTP and transform request data into a format that can be consumed by handlers and services.
155
+
156
+ Once execution reaches a service operation, the origin of a parameter becomes irrelevant. A service should behave identically regardless of whether it was invoked from an HTTP request, a background job, a Kafka consumer, a CLI command, or any other entry point.
157
+
158
+ Services should depend on input data, not on how that data was delivered.
159
+
160
+ ### Context over Hierarchy
161
+
162
+ Route structure should communicate the context required to execute an operation.
163
+
164
+ For example:
165
+
166
+ ```ruby
167
+ get '/organizations/:org_id/users'
168
+ ```
169
+
170
+ clearly indicates that users are being accessed within the context of a specific organization.
171
+
172
+ Nested routes are encouraged when the relationship between resources is meaningful to the operation being performed.
173
+
174
+ Examples:
175
+
176
+ ```text
177
+ /organizations/:org_id/users
178
+ /orders/:order_id/items
179
+ ```
180
+
181
+ However, nesting should remain reasonable.
182
+
183
+ Avoid using URLs to mirror the entire domain model:
184
+
185
+ ```text
186
+ /organizations/:org_id/departments/:department_id/teams/:team_id/users/:id
187
+ ```
188
+
189
+ Deep route hierarchies often introduce implementation details into the transport layer and make APIs harder to understand.
190
+
191
+ The goal is to express execution context, not to model the complete object graph.
192
+
193
+ ### Resource and Action Routes
194
+
195
+ Resource-oriented routes are encouraged:
196
+
197
+ ```ruby
198
+ get '/users/:id'
199
+ post '/users'
200
+ patch '/users/:id'
201
+ delete '/users/:id'
202
+ ```
203
+
204
+ However, not every business operation maps naturally to CRUD semantics.
205
+
206
+ When an action represents a meaningful business operation, it may be expressed explicitly in the route:
207
+
208
+ ```ruby
209
+ put '/users/:id/block'
210
+ put '/users/:id/unblock'
211
+
212
+ post '/orders/:id/cancel'
213
+ post '/organizations/:id/invite'
214
+ ```
215
+
216
+ Clarity is more important than strict adherence to REST conventions.
217
+
218
+ ### Design Guidelines
219
+
220
+ Routes should:
221
+
222
+ * Be easy to map to a handler or service operation.
223
+ * Express only the context required for the use case.
224
+ * Use path parameters for resource identification.
225
+ * Keep nesting shallow and meaningful.
226
+ * Prioritize readability and discoverability.
227
+
228
+ Routes should not:
229
+
230
+ * Mirror the entire domain hierarchy.
231
+ * Expose persistence-layer relationships.
232
+ * Depend on implementation details of the domain layer.
233
+
234
+ A well-designed route should make the purpose of an operation obvious while remaining independent from the implementation of the underlying business logic.
235
+
236
+ ## Operations
237
+
238
+ Operations represent application use cases.
239
+
240
+ Their responsibility is to define execution flow rather than implement business logic.
241
+
242
+ An operation composes reusable steps into an explicit Railway pipeline and orchestrates how data moves through the system.
243
+
244
+ Example:
245
+
246
+ ```ruby
247
+ def call(input = {})
248
+ result = step validate.(input, contract)
249
+ result = step create_user.(result)
250
+ result = step project.(result, projection)
251
+
252
+ result
253
+ end
254
+ ```
255
+
256
+ ### Data Flow
257
+
258
+ Operations receive an input hash as their entry point.
259
+
260
+ Each step receives the current state of the input and may:
261
+
262
+ * Read existing values.
263
+ * Add new values.
264
+ * Modify existing values.
265
+ * Return a transformed version of the input.
266
+
267
+ As execution progresses, the input is enriched and transformed by each step.
268
+
269
+ For example, a service operation may start with:
270
+
271
+ ```ruby
272
+ {
273
+ resource: Post,
274
+ attributes: { ... }
275
+ }
276
+ ```
277
+
278
+ then, after validation, remain the same shape (now validated):
279
+
280
+ ```ruby
281
+ {
282
+ resource: Post,
283
+ attributes: { ... }
284
+ }
285
+ ```
286
+
287
+ and after a create step that substitutes the persisted instance:
288
+
289
+ ```ruby
290
+ {
291
+ resource: #<Post instance>,
292
+ attributes: { ... }
293
+ }
294
+ ```
295
+
296
+ Handler operations use a different input shape — `{ params:, context: }` —
297
+ as described in [Handler Conventions](#handler-conventions).
298
+
299
+ This approach eliminates hidden state and makes every transformation explicit.
300
+
301
+ ### Responsibilities
302
+
303
+ Operations should:
304
+
305
+ * Define use-case execution flow.
306
+ * Compose steps into pipelines.
307
+ * Pass data between pipeline stages.
308
+ * Prepare arguments for individual steps.
309
+ * Remain easy to read and reason about.
310
+
311
+ Operations should not:
312
+
313
+ * Contain business logic.
314
+ * Access databases directly.
315
+ * Communicate with external services directly.
316
+ * Implement reusable workflow behavior.
317
+
318
+ The operation defines the workflow. The steps perform the work.
319
+
320
+ ## Steps
321
+
322
+ Steps are the executable units within an operation.
323
+
324
+ They represent the individual actions required to complete a use case and serve as the primary building blocks of a Railway pipeline.
325
+
326
+ A step should perform one clearly defined responsibility, such as:
327
+
328
+ * Validating input data.
329
+ * Loading a resource.
330
+ * Persisting changes.
331
+ * Authorizing access.
332
+ * Applying a projection.
333
+ * Transforming execution context.
334
+
335
+ Each step receives the current execution state and returns either:
336
+
337
+ * `Success(input)`
338
+ * `Failure(input)`
339
+
340
+ Following the Railway Pattern, a failure immediately short-circuits execution and returns control to the caller.
341
+
342
+ Because steps operate on a shared execution context, they can incrementally enrich and transform the input as it flows through the pipeline.
343
+
344
+ Steps should be:
345
+
346
+ * Small and focused.
347
+ * Easy to test in isolation.
348
+ * Reusable across multiple operations when appropriate.
349
+ * Responsible for a single piece of work.
350
+
351
+ Steps should not:
352
+
353
+ * Orchestrate execution flow.
354
+ * Contain unrelated responsibilities.
355
+ * Depend on hidden state.
356
+
357
+ Operations define the workflow. Steps perform the work.
358
+
359
+ ## Shared
360
+
361
+ Shared components provide reusable pipeline steps within a layer.
362
+
363
+ Each layer maintains its own Shared namespace:
364
+
365
+ * `handlers/shared`
366
+ * `services/shared`
367
+
368
+ Shared components encapsulate cross-cutting concerns that are reused throughout a layer while remaining independent from a specific domain.
369
+
370
+ ### Handler Shared
371
+
372
+ Handler Shared components focus on transport-level concerns.
373
+
374
+ Examples include:
375
+
376
+ * JWT validation and decoding.
377
+ * Current user resolution.
378
+ * Request metadata extraction.
379
+ * Header normalization.
380
+ * Context enrichment.
381
+
382
+ ### Service Shared
383
+
384
+ Service Shared components focus on domain-agnostic business operations.
385
+
386
+ Examples include:
387
+
388
+ * Contract validation.
389
+ * Pagination.
390
+ * Sorting and filtering.
391
+ * Resource loading.
392
+ * Projection generation.
393
+ * Generic authorization policies.
394
+
395
+ These steps can be reused across multiple services without introducing coupling to a specific resource or domain.
396
+
397
+ ### Design Guidelines
398
+
399
+ Shared components should:
400
+
401
+ * Operate on abstract requests, resources, collections, or execution context.
402
+ * Remain independent of any specific business domain.
403
+ * Be composable within Railway pipelines.
404
+ * Encapsulate commonly repeated workflow operations.
405
+
406
+ Shared components should not:
407
+
408
+ * Contain business-specific rules.
409
+ * Depend on a particular resource type unless explicitly intended.
410
+ * Replace domain services.
411
+
412
+ When logic becomes specific to a business capability, it should be implemented within a dedicated service instead of Shared.
413
+
414
+ ## Utils
415
+
416
+ Utils are single-purpose objects responsible for encapsulating implementation details.
417
+
418
+ Their purpose is to keep operations and steps focused on orchestration rather than low-level execution logic.
419
+
420
+ Unlike Steps and Shared components, utilities do not participate in Railway pipelines and should return direct results rather than monads.
421
+
422
+ Utilities typically expose a simple interface with explicit arguments and return direct results.
423
+
424
+ Utilities are registered within the container and injected using `include Import[]` in steps.
425
+
426
+ ### Typical Use Cases
427
+
428
+ * External API clients.
429
+ * Complex calculations.
430
+ * Data transformation.
431
+ * Serialization and deserialization.
432
+ * Token generation and verification.
433
+ * File processing.
434
+
435
+ ### Shared and Domain Utilities
436
+
437
+ Utilities may belong either to a specific domain or to a Shared namespace.
438
+
439
+ Domain-specific utilities support a single service or handler domain.
440
+
441
+ Examples:
442
+
443
+ * Payment gateway clients.
444
+ * Pricing calculators.
445
+ * Report generators.
446
+
447
+ Shared utilities provide functionality reusable across multiple domains.
448
+
449
+ Examples:
450
+
451
+ * JWT helpers.
452
+ * Generic HTTP clients.
453
+ * Cryptographic utilities.
454
+ * Serialization helpers.
455
+
456
+ ### Design Guidelines
457
+
458
+ Utils should:
459
+
460
+ * Have a single responsibility.
461
+ * Accept explicit arguments.
462
+ * Return direct results.
463
+ * Be easy to test in isolation.
464
+ * Encapsulate implementation details.
465
+
466
+ Utils should not:
467
+
468
+ * Control application flow.
469
+ * Orchestrate workflows.
470
+ * Depend on pipeline state.
471
+ * Replace services or shared steps.
472
+
473
+ Services and Shared Steps define what should happen. Utilities define how a specific task is performed.
474
+
475
+ ## Handler Conventions
476
+
477
+ Handlers act as pure request transformations.
478
+
479
+ ### Input
480
+
481
+ Handlers receive a hash containing:
482
+
483
+ * `params`
484
+ * `context`
485
+
486
+ The context typically contains request metadata such as headers, authentication data, and current user information.
487
+
488
+ ### Output
489
+
490
+ Handlers always return a `Rivulet::Response` wrapped in a monad:
491
+
492
+ * `Success(Rivulet::Response)`
493
+ * `Failure(Rivulet::Response)`
494
+
495
+ This guarantees that the framework always receives a valid response object regardless of whether execution reaches the end of the pipeline or terminates early through a failure path.
496
+
497
+ ### Rivulet::Response
498
+
499
+ `Rivulet::Response` is the standardized transport response object used throughout the framework.
500
+
501
+ Successful response:
502
+
503
+ ```ruby
504
+ Success(
505
+ Rivulet::Response.new(
506
+ status: 200,
507
+ body: {
508
+ data: resource
509
+ },
510
+ headers: {
511
+ "Content-Type" => "application/json"
512
+ }
513
+ )
514
+ )
515
+ ```
516
+
517
+ Failure response:
518
+
519
+ ```ruby
520
+ Failure(
521
+ Rivulet::Response.new(
522
+ status: 422,
523
+ body: {
524
+ error: "validation_failed"
525
+ }
526
+ )
527
+ )
528
+ ```
529
+
530
+ Attributes:
531
+
532
+ * `status` — HTTP status code.
533
+ * `body` — response payload.
534
+ * `headers` — optional HTTP headers.
535
+
536
+ The framework extracts the enclosed response object regardless of whether it arrives through a success path or a short-circuited failure path.
537
+
538
+ ## Service Conventions
539
+
540
+ Services encapsulate business use cases and remain independent from transport concerns.
541
+
542
+ ### Input
543
+
544
+ Services typically receive an input hash containing the resources and attributes required to execute the use case.
545
+
546
+ For example:
547
+
548
+ ```ruby
549
+ {
550
+ resource: Post,
551
+ attributes: { name: 'John Doe', email: 'john.doe@example.com' }
552
+ }
553
+ ```
554
+
555
+ The `resource` slot acts as a dependency-injection point: it enters as a
556
+ model class (e.g. `Post`, or any ancestor/subclass) and may be substituted
557
+ with a persisted instance as the pipeline progresses (e.g. after a create
558
+ step). The contract is responsible for validating the `resource` type —
559
+ including allowed classes or ancestors — alongside the `attributes`.
560
+
561
+ ### Output
562
+
563
+ Services return either:
564
+
565
+ * `Success(input)`
566
+ * `Failure(input)`
567
+
568
+ where the input hash contains the current execution state.
569
+
570
+ ### Validation
571
+
572
+ Services are expected to utilize contracts for data validation before performing business operations.
573
+
574
+ A shared `Validate` step runs the contract against the input and returns
575
+ `Success(result.to_h)` on success, or
576
+ `Failure[:validation, result.errors.to_h]` on errors:
577
+
578
+ ```ruby
579
+ module Services
580
+ module Shared
581
+ module Steps
582
+ class Validate < Rivulet::Step
583
+ def call(input, contract)
584
+ result = contract.call(input)
585
+ return Success(result.to_h) if result.success?
586
+
587
+ Failure[:validation, result.errors.to_h]
588
+ end
589
+ end
590
+ end
591
+ end
592
+ end
593
+ ```
594
+
595
+ ### Projection
596
+
597
+ Projections define the output contract of a service.
598
+
599
+ Rather than exposing persistence-layer models directly, services transform their results into an explicit structure that describes exactly which fields, relationships, and transformations are available to callers.
600
+
601
+ Rivulet currently uses `dry-transformer` for building projections. Keeping projections declarative makes service outputs predictable, easy to reason about, and independent from underlying persistence implementations. This creates a clear boundary between domain logic and the data exposed to higher layers.
602
+
603
+ ## Contracts
604
+
605
+ Contracts define and validate the input required by a service operation.
606
+
607
+ Rivulet uses `dry-validation` to describe expected data structures and enforce input correctness before business logic is executed.
608
+
609
+ Beyond validation, contracts serve as explicit documentation of a service's requirements.
610
+
611
+ A contract should describe exactly what a service needs in order to perform its work.
612
+
613
+ For example, if a service only requires an IP address and a User-Agent, the contract should explicitly define those attributes:
614
+
615
+ ```ruby
616
+ required(:ip).filled(:string)
617
+ required(:user_agent).filled(:string)
618
+ ```
619
+
620
+ rather than accepting a larger object such as:
621
+
622
+ ```ruby
623
+ required(:request)
624
+ ```
625
+
626
+ and extracting values internally.
627
+
628
+ Explicit contracts make dependencies visible, improve testability, and reduce coupling between services and transport-specific objects.
629
+
630
+ ### Responsibilities
631
+
632
+ Contracts should:
633
+
634
+ * Validate input data.
635
+ * Define the input boundary of a service.
636
+ * Describe required and optional attributes explicitly.
637
+ * Remain independent of transport-layer abstractions.
638
+
639
+ Contracts should not:
640
+
641
+ * Contain business logic.
642
+ * Perform side effects.
643
+ * Depend on HTTP request objects or framework-specific structures.
644
+
645
+ ### Design Philosophy
646
+
647
+ Services should receive only the data they actually require.
648
+
649
+ Passing explicit attributes through contracts creates a clear and stable interface between callers and service operations, regardless of whether the service is invoked from HTTP, background jobs, message consumers, or CLI commands.
650
+
651
+ The smaller and more explicit a contract is, the easier the service becomes to understand, test, and reuse.