This guide explains how an application becomes a stable set of descriptions, then creates independent execution scopes from them. You will observe a scoped resource being reused within one invocation and closed when that invocation finishes.
You should understand the roles in Objects and responsibilities. The application API is intentionally small: beans and factories describe construction, operations describe intentions and policies, and modules describe startup and shutdown.
Declare collaborators before startup
Application#factory registers collaborators with explicit dependencies. Factory blocks receive those dependencies as keyword arguments:
app.factory(:orders, dependencies: [:session]) do |session:| Orders.new(session:) end app.operation(:confirm_order, use_case: ConfirmOrder, dependencies: [:orders], policy: :write)
Application#operation binds ConfirmOrder to its dependencies and policy. The orders factory belongs to the composition root. ConfirmOrder receives an Orders instance; it never needs the factory name. This keeps wiring decisions visible while leaving the use case independent of the container.
Factories run on demand inside a scope. The scope caches each named result, so two collaborators depending on orders receive the same repository for that invocation. A later invocation has a new cache and a new use-case object.
The reserved dependencies are context, transaction, and session. Context is available to every policy. Transaction and session require a storage policy; session also requires mappings. A factory cannot shadow these names.
Register request-scoped beans
Application#bean registers an ordinary Ruby class or a builder block. It infers dependencies from required keyword arguments, so a repository whose constructor is initialize(session:) can be registered directly:
app.bean(:orders, Orders) app.operation(:confirm_order, use_case: ConfirmOrder, policy: :write)
ConfirmOrder#initialize(orders:) receives the repository, and Orders#initialize(session:) receives this invocation’s session. Neither class needs to inherit from the framework or access the application. Bean names match constructor keyword names; registration remains explicit rather than scanning loaded classes.
Beans belong to one invocation. Startup validates their dependency graph without constructing them. The invocation builds only the beans reached through its operation’s dependencies and caches each named result until that invocation ends. A second request, including one on the same HTTP connection, builds fresh instances. Direct calls through app.invoke have the same lifetime as requests entering through HTTP or gRPC operations.
Run a complete example:
require "object_framework" class RequestCounter attr_reader :operation_id def initialize(context:) @operation_id, @count = context.operation_id, 0 end def next_value @count += 1 end def close puts "Counter closed" end end class CountTwice def initialize(first:, second:) @first, @second = first, second end def call { counts: [@first.next_value, @second.next_value], operation_id: @first.operation_id } end end app = ObjectFramework::Application.new app.bean(:counter, RequestCounter, close: true) app.bean(:first) { |counter:| counter } app.bean(:second) { |counter:| counter } app.operation(:count, use_case: CountTwice, policy: :none) app.start begin results = 2.times.map do |index| result = app.invoke(:count) puts "Request #{index + 1}: #{result[:counts].join(', ')}" result end puts "Independent contexts: #{results[0][:operation_id] != results[1][:operation_id]}" ensure app.stop end
ruby -Ilib examples/guides/14_request_beans.rb
Expected output:
Counter closed Request 1: 1, 2 Counter closed Request 2: 1, 2 Independent contexts: true
The wiring in that example is:
app.bean(:counter, RequestCounter, close: true) app.bean(:first) { |counter:| counter } app.bean(:second) { |counter:| counter } app.operation(:count, use_case: CountTwice, policy: :none)
Both branches receive the same counter within a request, so its two reads produce 1, 2. The next request starts at 1 again. Its counter also receives a fresh context. Only the counter registration owns cleanup: the branches return its instance without registering another close call. Cleanup happens before app.invoke returns, including when building a dependent bean or calling the use case raises.
Only required keywords are inferred. For initialize(clock: nil), Ruby’s default remains in effect even if a clock bean exists. Use app.bean(:service, Service, dependencies: [:clock]) to inject that optional keyword explicitly. Operations and builder blocks accept the same override. For a positional constructor, use a block such as app.bean(:client) { |endpoint:| Client.new(endpoint) }.
Beans and factories use the same registry and scope, so their dependencies can refer to each other. Missing dependencies, cycles, reserved names, duplicate registrations, and registration after startup are rejected. Existing factory(..., dependencies: [...]) declarations retain their behavior. The bean scenarios exercise concurrent invocations, keep-alive HTTP requests, startup failures, transaction dependencies, and resource cleanup.
Observe scope ownership
Run this complete example:
require "object_framework" class Resource def initialize(events:) @events = events @events << "opened" end def close @events << "closed" end end class CompareResources def initialize(first:, second:) @first, @second = first, second end def call @first.equal?(@second) end end events = [] app = ObjectFramework::Application.new app.factory(:resource, close: true) { Resource.new(events:) } app.factory(:first, dependencies: [:resource]) { |resource:| resource } app.factory(:second, dependencies: [:resource]) { |resource:| resource } app.operation(:compare, use_case: CompareResources, policy: :none, dependencies: [:first, :second]) app.start begin puts "Same resource: #{app.invoke(:compare)}" puts events.join(", ") ensure app.stop end
ruby -Ilib examples/guides/04_scoped_resources.rb
Expected output:
Same resource: true opened, closed
Both first and second depend on one named resource. close: true gives the scope responsibility for calling its close method. Resources close in reverse construction order. Repeating app.invoke(:compare) opens and closes a second resource; it does not keep the first execution alive.
Use this ownership rule for short-lived collaborators such as a per-execution client or file handle. Long-lived listener and storage lifecycles belong to application modules or to explicit outer ensure blocks. A factory that closes a shared singleton would give one request responsibility for another request’s resource.
Choose an explicit transaction policy
Every operation declares exactly one policy:
| Policy | Behavior |
|---|---|
:none |
Construct a scope and invoke the use case without storage. |
:read |
Open a read transaction and, when mappings exist, a session. |
:write |
Open a write transaction, track changes, flush, and commit. |
Read and write operations both close their transactions. The adapter enforces read-only behavior. Mutating a managed object during a read operation does not silently upgrade its transaction; flushing the change fails.
The policy is a reviewable part of the operation description. Inferring it from HTTP methods would make direct callers and other transports behave differently. Inferring it from the first SQL statement would make resource ownership depend on a path through application code.
Follow the invocation boundary
Application#invoke executes this sequence:
-
Check cancellation and create the scope.
-
Open the declared transaction and session.
-
Build scoped collaborators and the ordinary use-case instance.
-
Call the use case and materialize independent public values.
-
Check cancellation, flush managed changes, and commit.
-
Close the session, scoped resources, and transaction in
ensure.
Materialization happens before commit so that an unsupported result can still roll back the operation. Presentation happens afterward so that a formatter’s failure cannot falsely imply that committed effects disappeared.
An exception before commit rolls back and discards the scope. If rollback or close also fails, the original exception remains primary and secondary failures are reported. Cleanup also runs while a fatal exception unwinds the thread, although the normal failure boundary classifies StandardError.
Compose application modules
Application#install registers a module supplying name, requires, configure(application), start, and stop. It may also supply validate!. For example, HTTP and gRPC servers act as modules:
app.install(http_server) app.install(grpc_server) app.start begin # The application owns the running listeners here. ensure app.stop end
Startup orders module dependencies, configures all modules, and validates dependencies, mappings, routes, use cases, and error registrations before any module starts. Only after validation succeeds are listeners opened. If a later module fails to start, already-started modules stop in reverse order.
This is a small protocol rather than an inheritance hierarchy. An adapter can participate without adopting framework-specific lifecycle state in its domain objects. The cost is explicit composition and the requirement that module configuration finish before startup.
Keep execution state local
Create two Application instances to host independent applications in one process. Each owns its registries, factories, and operations. Request-specific state belongs in locals or scoped collaborators. A controller is usually long-lived, so storing @current_session or @current_order on it would mix concurrent executions.
Inject ExecutionContext through context when a use case needs cooperative cancellation or its operation identifier. Call Cancellation#check! between bounded pieces of long work. The cancellation object communicates intent; the framework does not kill arbitrary Ruby code in the middle of a state change.
The invocation scenarios cover successful commit, rollback, indeterminate commit outcomes, cleanup failures, cancellation, and fatal unwinding.
Next step
Read Data Mapper and sessions to see what the storage scope actually manages. Before moving on, change the resource example so that CompareResources#call raises, and verify that the closed event is still recorded in an outer rescue or diagnostic sink.