HARD-GATE
TESTS GATE IMPLEMENTATION:
EVERY service object MUST have its test written and validated BEFORE implementation.
1. Write the spec/test for .call (with contexts for success, error, edge cases)
2. Run the spec/test — verify it fails because the service does not exist yet
3. ONLY THEN write the service implementation
The final artifact must include the test command and the failure message
before implementation. If execution is unavailable, report the gate as blocked;
expected output cannot substitute for an observed failure.
See tdd-process for the full gate cycle.
Core Process
- Write Spec (Test-First): Create the spec/test file at
spec/services/<module_name>/<service_name>_spec.rb(ortest/services/). Cover success and error paths for.call. Run it to confirm it fails (see HARD-GATE). For a new service without an established result convention, assertsuccess:andresponse:top-level keys; preserve the existing public result shape for established services. - Define Service Skeleton: Create
services/<module_name>/<service_name>.rbwith the correct module namespace. - Select Pattern: Choose Standard, Batch, Class-only (Pattern 3), or Orchestrator based on requirements. State whether instance state is required — if not, use Pattern 3 (no
initialize, no instance variables). - Implement Contract: Implement
self.calland#call. Preserve the existing public result contract; for a new service without a convention, use{ success: true, response: { ... } }or{ success: false, response: { error: { message: '...' } } }. Keepcall≤ 20 lines; extract sub-services if longer. Validate inputs at top ofcall; return error hash if invalid. Return serialized data only — no raw persistence model objects (e.g. ActiveRecord, ROM) inresponse. - Handle Errors and Logging: Rescue only expected domain or transport errors that this service can recover from. Convert them to the established error result, log once with the configured logger, and propagate unexpected defects so monitoring, transaction rollback, and job retries work. Keep sensitive exception details out of public messages.
- Add YARD Documentation: Add applicable
@param, correctly typed@return, and escaping-exception@raisetags toself.calland every other public method. Documentself.callseparately from#call. For class-only services (Pattern 3), if the class returns a non-standard shape (e.g.nil/ error string), document that explicitly in YARD and the README. - Write Module README: Generate
services/<module_name>/README.mdexplaining domain context. Required even for single-service modules.
Additional Constraints
| Aspect | Rule |
|---|---|
| Transactions | Only wrap multi-step database operations that must be atomic |
| Scope | Return data only (no HTTP/UI concerns); single responsibility per service |
| SQL | Use query sanitization for any dynamic queries |
| Shared logic | Extract validators to class-only services (Pattern 3) |
Core Patterns
1. The .call Pattern
# DomainError is the project-defined recoverable business error.
def self.call(params)
new(params).call
end
def call
# ... processing ...
{ success: true, response: { data: result } }
rescue DomainError => e
logger.error({ event: "service.processing_failed", error_class: e.class.name, backtrace: Array(e.backtrace).first(5) }.to_json)
{ success: false, response: { error: { message: ERROR_MESSAGE } } }
end
2. Batch Processing + Per-Item Rescue (Partial Success)
def call
results = @items.each_with_object({ successful: [], failed: [] }) do |item, acc|
# process...
rescue DomainError => e
logger.error({ event: "service.processing_failed", error_class: e.class.name, backtrace: Array(e.backtrace).first(5) }.to_json)
acc[:failed] << { sku: item[:sku], error: e.message }
end
{ success: true, response: results }
end
3. Class-only Services (Static Methods)
When no instance state is needed, use ONLY class methods — no initialize, no instance variables. Suitable for validators, formatters, and argument-only helpers.
class Orders::QuantityValidator
def self.call(quantity:)
return { success: false, response: { error: { message: INVALID_QUANTITY } } } unless quantity.positive?
{ success: true, response: { valid: true } }
end
end
4. Orchestrator Delegation (≤20-line call)
def call
user_result = UserCreationService.call(@params)
return user_result unless user_result[:success]
# ... continue ...
end
Extended Resources (Progressive Disclosure)
Load these files only when their specific content is needed:
- assets/examples.md — Detailed examples of the 4 core patterns (Standard, Batch, Static, Orchestrator).
- assets/service_skeleton.md — Basic starting skeleton.
- assets/module_readme_template.md — Template for the mandatory module README.
Integration
| Skill | When to chain |
|---|---|
| write-yard-docs | Writing/reviewing inline docs |
| integrate-api-client | External API integrations |
| implement-calculator-pattern | Variant-based calculators |
| write-tests | General testing structure |
| refactor-process | Refactoring service objects |