This document explains the architectural decisions, design patterns, and underlying philosophy that guide Solid::Process. It covers emergent design principles, core patterns implemented in the framework, and rationale for key design decisions. For configuration of these systems, see Configuration API. For the concrete implementation details of core concepts, see Core Concepts.
Solid::Process embraces emergent design as a core principle. Business rules are directly coupled with business needs, and clarity improves over time through maintenance cycles. Rather than forcing developers to adopt complex patterns upfront, the framework supports incremental sophistication.
The development philosophy follows a three-stage progression documented in docs/overview/010_KEY_CONCEPTS.md36-40:
Stage 1: Make it Work — Start with the minimal structure: an input block defining attributes and a call method returning Success or Failure. This provides immediate value without ceremony. See README.md115-131 for the basic structure.
Stage 2: Make it Better — Add validations, input normalization, and the Steps DSL (Given, and_then, Continue, and_expose) as patterns emerge. The process gains structure while remaining readable. Implementation in lib/solid/process.rb
Stage 3: Make it Even Better — Introduce dependency injection via the deps block, process composition, and lifecycle callbacks when complexity demands it. These features exist to solve real problems, not as mandatory ceremony.
Sources: README.md42-43 docs/overview/010_KEY_CONCEPTS.md31-44 docs/REFERENCE.md43-53
The framework provides an intuitive entry point for novices while offering robust features for experienced developers, as stated in README.md52-55 Each layer adds capability without invalidating previous knowledge.
Sources: README.md44-62 docs/REFERENCE.md43-53
Each process class has one job: orchestrate a single business operation. The class defines what inputs it needs, what steps it performs, and what outcomes it produces. All other concerns (repositories, mailers, external services) are injected as dependencies.
The process acts as an orchestrator, not a worker. It coordinates collaborators rather than performing work directly. This design appears in docs/overview/010_KEY_CONCEPTS.md10-15 and docs/REFERENCE.md28-33
Sources: docs/overview/010_KEY_CONCEPTS.md9-24 docs/REFERENCE.md28-38 lib/solid/process.rb
The framework implements railway-oriented programming through the Steps DSL. Each step either continues on the "success track" (Continue) or switches to the "failure track" (Failure). Once on the failure track, subsequent steps are skipped.
The pattern is implemented in the Caller module lib/solid/process/caller.rb which prepends the execution lifecycle. The Steps DSL methods Given, and_then, Continue, and and_expose create this railway structure as documented in docs/REFERENCE.md607-720
Key insight: Each step receives accumulated data as keyword arguments. The ** splat operator captures unknown keys, allowing steps to extract only what they need: def validate_email(email:, **).
Sources: docs/REFERENCE.md607-720 lib/solid/process/caller.rb docs/overview/030_INTERMEDIATE_USAGE.md
Input validation acts as a hard gate before business logic executes. Invalid input immediately returns Failure(:invalid_input) without calling the call method. This enforces the principle: never execute business logic with invalid data.
Implementation in lib/solid/process/caller.rb validates input before the call method executes. The input object is an instance of Solid::Input lib/solid/input.rb which includes Solid::Model lib/solid/model.rb for ActiveModel integration.
Sources: lib/solid/process/caller.rb lib/solid/input.rb lib/solid/model.rb docs/REFERENCE.md224-237
The deps block creates a Dependencies class (subclass of Solid::Model) that defines collaborators with defaults. This enables both production use with real implementations and testing with mocks.
The pattern is defined in lib/solid/process/class_methods.rb where the deps block creates the Dependencies constant. Access dependencies via deps or dependencies method inside the process.
Validation: Dependencies support ActiveModel validations. Invalid dependencies return Failure(:invalid_dependencies) before calling the call method, similar to input validation.
Sources: lib/solid/process/class_methods.rb docs/REFERENCE.md827-903 docs/overview/040_ADVANCED_USAGE.md
Processes can call other processes, creating hierarchical workflows. The parent process treats child processes as dependencies, injecting them for testability.
Error Wrapping: Parent processes wrap child failures to maintain context:
This creates nested failure types like Failure(:invalid_owner, email_already_taken: {...}), preserving both what failed (:invalid_owner) and why (:email_already_taken). Pattern documented in docs/REFERENCE.md935-1040
Sources: docs/REFERENCE.md935-1040 examples/business_processes/lib/account/owner_creation.rb
All processes return Solid::Result objects (either Solid::Success or Solid::Failure). Results carry both a type (describing what happened) and a value (the data).
Type Safety: Result types create a vocabulary for outcomes. Instead of checking string error messages or exception types, callers check semantic types:
:user_created vs :email_already_taken vs :invalid_inputresult.user_created? returns true if result.type == :user_createdImplementation uses solid-result gem as the foundation. See docs/REFERENCE.md399-493 for the result API.
Sources: docs/REFERENCE.md399-493 lib/solid/process.rb CHANGELOG.md64
Process instances are stateful and can only execute once. Attempting to call an instance twice raises Solid::Process::Error. This design ensures:
The immutability is enforced in lib/solid/process/caller.rb which checks if @output is already set. The class method .call automatically creates a new instance each time lib/solid/process.rb
Rationale: This prevents accidental state leakage between executions and makes processes behave like pure functions (same input → same output).
Sources: lib/solid/process/caller.rb docs/REFERENCE.md144-166
The framework favors explicit constructs over implicit "magic":
| Decision | Explicit Approach | Rationale |
|---|---|---|
| Inputs | Declare with input do ... end | Type safety, documentation, validation |
| Results | Return Success(:type, data) or Failure(:type, data) | Semantic types over boolean/nil |
| Steps | Chain with and_then(:method_name) | Clear execution order |
| Dependencies | Inject with deps do ... end | Testability, no hidden globals |
| Errors | rescue_from → Failure | Convert exceptions to values |
No implicit coercion: If a step returns something other than Continue/Success/Failure, the framework does not try to "fix" it — it fails fast with a clear error.
No auto-rescue: Exceptions propagate unless explicitly handled with rescue_from. This prevents silent failures.
Sources: README.md44-68 docs/REFERENCE.md43-53
Rather than reinventing validation, the framework integrates with ActiveModel (lib/solid/model.rb7-8). This provides:
validates :email, presence: trueConditional features: Solid::Model detects Rails version and includes ActiveModel::Attributes::Normalization only when available (Rails 8.1+). See lib/solid/model.rb9-11
Custom validators: The framework provides domain-specific validators (email, uuid, id, kind_of, etc.) in lib/solid/validators that follow ActiveModel conventions.
Sources: lib/solid/model.rb lib/solid/input.rb lib/solid/validators CHANGELOG.md7-9
The framework maintains clear boundaries:
| Concern | Responsible Class | Purpose |
|---|---|---|
| Data validation | Solid::Input (via Solid::Model) | Ensure data integrity |
| Business logic | Solid::Process | Orchestrate operations |
| Results | Solid::Result | Represent outcomes |
| Observability | EventLogs + BasicLoggerListener | Trace execution |
| Dependencies | Dependencies (via Solid::Model) | External collaborators |
Each class has a single responsibility and can be used independently:
Solid::Model — Standalone ActiveModel objects lib/solid/model.rbSolid::Value — Immutable value objects lib/solid/value.rbSolid::Input — Input validation without processes lib/solid/input.rbThese are documented as "Internal Libraries" in docs/REFERENCE.md1512-1612 and docs/overview/090_INTERNAL_LIBRARIES.md
Sources: lib/solid/model.rb lib/solid/value.rb lib/solid/input.rb docs/overview/090_INTERNAL_LIBRARIES.md
| Aspect | Service Objects | Solid::Process |
|---|---|---|
| Input validation | Manual if input.valid? checks | Automatic validation gate |
| Result handling | Boolean, nil, or raise exceptions | Typed Success/Failure results |
| Composition | Ad-hoc method calls | Dependency injection + Steps DSL |
| Observability | Manual logging | Built-in event logging |
| Testing | Mock at method level | Inject dependencies at class level |
When to use Service Objects: Simple operations that don't need validation, composition, or observability.
When to use Solid::Process: Multi-step business operations with clear inputs/outputs and observable execution.
| Aspect | Interactor | Solid::Process |
|---|---|---|
| Control flow | Context mutation | Functional pipeline (Continue/Success/Failure) |
| Composition | Organizer with array of interactors | Dependency injection + explicit calls |
| Rails integration | Separate philosophy | Embraces Rails conventions |
| Steps DSL | No built-in steps | Given, and_then, Continue, and_expose |
Interactor uses a shared context object that gets mutated. Solid::Process passes immutable data through a functional pipeline.
| Aspect | Dry::Transaction | Solid::Process |
|---|---|---|
| Dependencies | Dry-container | deps block with defaults |
| Type system | Dry-types required | Optional ActiveModel types |
| Steps syntax | DSL with step, tee, map | and_then, Continue, Success |
| Rails integration | Minimal | First-class (ActiveRecord, ActiveModel) |
Dry::Transaction requires buying into the entire Dry-rb ecosystem. Solid::Process works standalone or with Rails conventions.
Sources: README.md40-68 docs/overview/010_KEY_CONCEPTS.md26-29
Sources: README.md40-68 docs/overview/010_KEY_CONCEPTS.md
1. Multi-Step Business Operations
Operations with clear inputs, multiple steps, and meaningful outcomes:
✓ User registration (validate → create user → create token → send email)
✓ Order processing (validate → reserve inventory → charge card → ship)
✓ Account provisioning (create account → create owner → send invite)
2. Operations Requiring Observability
When you need to trace execution for debugging or auditing:
✓ Payment processing (log each step for compliance)
✓ Data imports (track progress through stages)
✓ Background jobs (understand failure points)
3. Operations with Complex Validation
When input validation goes beyond simple presence checks:
✓ Composite validations (password + confirmation)
✓ Business rule validations (email uniqueness)
✓ Dependent validations (field X required if field Y present)
4. Operations That Compose
When larger operations are built from smaller ones:
✓ Account creation → User creation → Token creation
✓ Order fulfillment → Payment → Shipping → Notification
5. Operations Needing Transaction Management
When database changes must be atomic:
✓ User + profile creation (rollback both on failure)
✓ Moving money between accounts (atomic transfer)
✓ Multi-record updates (all or nothing)
| Scenario | Use Instead |
|---|---|
| Simple queries | ActiveRecord scopes or query objects |
| Single database operation | Direct model method (e.g., User.create!) |
| Pure calculations | Plain Ruby method or function |
| Controller glue code | Controller action or before_action |
| One-line operations | Direct method call (over-engineering) |
Sources: README.md40-68 docs/overview/010_KEY_CONCEPTS.md26-29 docs/REFERENCE.md28-53
Anti-pattern: A single process that does too many unrelated things.
Solution: Create separate processes for each operation:
User::CreationUser::UpdateUser::DeletionUser::EmailSendingAnti-pattern: Calling the same instance multiple times.
Solution: Use class method or create new instances:
Anti-pattern: Checking only success/failure without examining the type.
Solution: Handle specific failure types:
Anti-pattern: Database writes across multiple steps without transaction protection.
Solution: Wrap database steps in rollback_on_failure:
** SplatAnti-pattern: Step methods that don't use ** to ignore extra keys.
Solution: Always use ** in step signatures:
Anti-pattern: Excessive nesting makes debugging and maintenance difficult.
Solution: Limit nesting to 2-3 levels. If you need more, reconsider your decomposition.
Anti-pattern: Wrapping trivial operations in process ceremony.
Solution: Use direct ActiveRecord or a simple query object:
Sources: docs/REFERENCE.md144-166 docs/REFERENCE.md662-680 docs/REFERENCE.md722-823
| Principle | Implementation |
|---|---|
| Emergent Design | Start simple, add sophistication incrementally |
| Single Responsibility | One process = one business operation |
| Railway-Oriented | Steps DSL with Continue/Success/Failure |
| Explicit Over Implicit | Clear inputs, outputs, and dependencies |
| Validation Gate | Invalid input never reaches business logic |
| Single-Use Instances | Each execution is isolated |
| Composition | Build complex from simple via dependency injection |
| Observability | Built-in event logging for debugging |
The framework balances pragmatism (Rails conventions, incremental adoption) with rigor (explicit types, input validation) to create maintainable business logic that scales with application complexity.
Sources: README.md40-68 docs/overview/010_KEY_CONCEPTS.md docs/REFERENCE.md43-53
Refresh this wiki