ObjectForge
[!TIP] You may be viewing documentation for an older (or newer) version of the gem than intended. Look at Changelog to see all versions, including unreleased changes.
ObjectForge is a small factory library for Ruby objects with minimal assumptions about framework, persistence, or runtime environment.
It is designed for cases where factory-style object construction is useful, but Rails-oriented or database-oriented tooling is a poor fit. ObjectForge works well with plain Ruby objects, hashes, structs, and custom build flows.
The library focuses on:
- explicit configuration over hidden conventions
- support for independent registries and standalone factories
- replaceable components based on simple interfaces
- usefulness both outside of tests and inside them
If you need factory-style object generation without coupling it to Rails, ActiveRecord, or a particular application structure, ObjectForge might be for you.
Table of contents
- Motivation
- Installation
- Usage
- Differences and limitations (compared to FactoryBot)
- Current and planned features (roadmap)
- Development
- Contributing
- License
Motivation
Ruby already has well-known factory libraries, especially FactoryBot and Fabrication. Those tools are effective in many projects, particularly when working in Rails applications and persistence-oriented test setups.
ObjectForge aims at a different problem space: building objects with a factory-style workflow while making as few assumptions as possible about framework, storage, object lifecycle, or application structure.
ObjectForge is particularly useful when:
- the objects being built are plain Ruby objects rather than database-backed records
- object generation is needed outside of tests, such as in services, scripts, or fixtures
- multiple independent sets of factories need to coexist in the same project
- construction behavior should be explicit and configurable rather than hidden behind framework conventions
The project is intentionally small in scope. Rather than trying to model every style of factory workflow, it focuses on a compact, understandable core:
- a DSL for defining attributes, sequences, and traits
- forges (factories) and forgeyards (registries)
- several object molds (constructors)
- a couple other helper components
The goal is to have a simple, composable tool that you can easily reach for when heavier libraries don't fit or feel like overkill.
Installation
Install with gem:
gem install object_forge
Or, if using Bundler, add to your Gemfile:
gem "object_forge"
and run bundle install.
Usage
[!Note]
- Latest documentation from
mainbranch is automatically deployed to GitHub Pages.- Documentation for published versions is available on RubyDoc.
Quick start
Create your domain logic class:
class Rectangle
def initialize(length:, width:)
@length = length
@width = width
end
def area = @length * @width
def inspect = "[#{@length}x#{@width}]"
end
Define a forge:
require "object_forge"
ObjectForge.define(:rectangle, Rectangle) do |f|
f.mold = ObjectForge::Molds::KeywordsMold.new
f.length { rand(1..100) }
f.width { rand(1..100) }
f.trait :square do |t|
t.width { length }
end
end
Forge some objects!
ObjectForge.forge(:rectangle) # => [63x27]
ObjectForge.forge(:rectangle, :square) # => [56x56]
ObjectForge.forge(:rectangle, width: 3333) # => [79x3333]
ObjectForge.forge(:rectangle, :square, length: 123) # => [123x123]
Basics
In the simplest cases, ObjectForge can be used much like other factory libraries, with definitions living in a global object (ObjectForge::DEFAULT_YARD). In this case, methods are called directly on ObjectForge module.
Forges are defined using a DSL:
# Example class:
Point = Struct.new(:id, :x, :y)
ObjectForge.define(:point, Point) do |f|
# Attributes can be defined using `#attribute` method:
f.attribute(:x) do
# Inside attribute definitions, other attributes can be referenced by name, in any order!
rand(-delta..delta)
end
# `#[]` is an alias of `#attribute`:
f[:y] { rand(-delta..delta) }
# There is also the familiar shortcut using `method_missing`:
f.delta { 0.5 * amplitude }
# Notice how transient attributes don't require any special syntax:
f.amplitude { 1 }
# `#sequence` defines a sequenced attribute (starting with 1 by default):
f.sequence(:id, "a")
# Traits allow to group and reuse related values:
f.trait :z do
f.amplitude { 0 }
# Sequence values are forge-global, but traits can redefine blocks:
f.sequence(:id) { |id| "Z_#{id}" }
end
# Trait's block can receive DSL object as a parameter:
f.trait :invalid do |tf|
tf.y { Float::NAN }
# `#[]` method inside attribute definition can be used to reference attributes:
tf.id { self[:x] }
end
end
A forge builds objects, using attributes hash:
ObjectForge.call(:point)
# => #<struct Point id="a", x=0.17176955469852973, y=0.3423901951181103>
# Positional arguments define used traits:
ObjectForge.build(:point, :z)
# => #<struct Point id="Z_b", x=0.0, y=0.0>
# Attributes can be overridden with keyword arguments:
ObjectForge.forge(:point, x: 10)
# => #<struct Point id="c", x=10, y=-0.3458802496120402>
# Traits and overrides are combined in the given order:
ObjectForge.call(:point, :z, :invalid, id: "NaN")
# => #<struct Point id="NaN", x=0.0, y=NaN>
# A Proc override behaves the same as an attribute definition:
ObjectForge.call(:point, :z, x: -> { rand(100..200) + delta })
# => #<struct Point id="Z_d", x=135.0, y=0.0>
# A block can be passed to do something with the created object:
ObjectForge.call(:point, :z) { puts "#{_1.id}: #{_1.x},#{_1.y}" }
# outputs "Z_e: 0.0,0.0"
[!TIP] Forging can be done through any of
#call,#forge, or#buildmethods, they are aliases.
Independent forgeyards and forges
It is possible and encouraged to create multiple forgeyards, each with its own set of forges:
forgeyard = ObjectForge::Forgeyard.new
forgeyard.define(:dot, Point) do |f|
f.sequence(:id, "a")
f.x { rand(-radius..radius) }
f.y { rand(-radius..radius) }
f.radius { 0.5 }
f.trait :z do f.radius { 0 } end
end
Now, this forgeyard can be used just like the default one:
forgeyard.forge(:dot, :z, id: "0")
# => #<struct Point id="0", x=0, y=0>
Note how the forge isn't registered in the default forgeyard:
ObjectForge.forge(:dot)
# KeyError: key not found
If you find it more convenient not to use a forgeyard (for example, if you only need a single forge for your service), you can create individual forges:
forge = ObjectForge::Forge.define(Point) do |f|
f.sequence(:id, "a")
f.x { rand(-radius..radius) }
f.y { rand(-radius..radius) }
f.radius { 0.5 }
f.trait :z do f.radius { 0 } end
end
Forge has the same building interface as a Forgeyard, but it doesn't have the name argument:
forge.build
# => #<struct Point id="a", x=0.3317733939650964, y=-0.1363936629550252>
forge.forge(:z)
# => #<struct Point id="b", x=0, y=0>
forge.(radius: 500)
# => #<struct Point id="c", x=-141, y=109>
Molds: configuring object construction
If you use core Ruby data containers, such as Struct, Data or even Hash, they will "just work". However, if a custom class is used, forging will probably fail, unless your class happens to take a hash of attributes in #initialize. It would be against the goal of ObjectForge to place requirements on your classes, and indeed there is a solution.
Whenever you need to change how your objects are built, you specify a mold. Molds are just callable objects (including Procs!) with specific arguments. They are set in forge definition:
forge = ObjectForge::Forge.define(Point) do |f|
f.mold = ->(forge_target:, attributes:, **) do
forge_target.new(attributes[:id], attributes[:x].round(3), attributes[:y].round(3))
end
#... rest of the definition from the previous example
end
Now the specified mold will be called to build your objects:
forge.forge
# => #<struct Point id="a", x=0.331, y=-0.136>
Of course, you can abuse this to your heart's content. Look at the documentation for ObjectForge::Molds for inspiration.
ObjectForge comes pre-equipped with a selection of molds for common cases:
ObjectForge::Molds::SingleArgumentMold(the default) callsnew(attributes), suitable for ActiveModel-style objects and Dry::Struct, as an exampleObjectForge::Molds::KeywordsMoldcallsnew(**attributes), suitable for Data and similar classesObjectForge::Molds::HashMoldallows building Hash (including subclasses), providing a way to easily use build hashesObjectForge::Molds::StructMoldhandles all possible cases ofkeyword_initfor Structs
[!NOTE] If you don't specify a mold, ObjectForge will infer one for core data containers, including Hash, Struct, and Data subclasses.
[!TIP] It is recommended to use molds instances directly. Using classes causes memory churn and lowered performance. Not only that, but having a stateful mold is a code smell and probably represents a significant design issue.
After-build customization
If there is a need to modify the object or perform additional actions after it is forged, there are two mechanisms you can employ:
- after-forge hook
- customization block
After-forge hook is a callable object specified as part of forge definition. It runs every time forging happens:
forge = ObjectForge::Forge.define(Rectangle) do |f|
# can also be specified as `after_build`
f.after_forge = ->(rect) { puts "Used #{rect.area} sq. units" }
#... rest of the definition from the Quick start example
end
forge.forge
# Used 621 sq. units
# => [23x27]
Customization block is an optional block argument to #forge and is only executed in that specific invocation:
forge.forge { |rect| RectangleRepository.save(rect); puts "persisted!" }
# Used 621 sq. units
# persisted!
# => [23x27]
If both hook and block are used, the hook runs before the block.
Performance tips
ObjectForge is pretty fast for what it is. However, if you are worried, there are certain things that can be done to make it faster.
- The easiest thing is to enable YJIT. It will probably speed up your whole application, but be aware that it is not always suitable and may even degrade performance on some workloads. It is considered production-ready though.
- Calling a Forge directly, instead of through Forgeyard, is faster due to not needing argument forwarding. This is consistent (but check on your system anyway!).
- Using
self[:name]instead of plainnameinside attribute definitions does not engage dynamic method dispatch, which should be faster. However, micro-benchmarking does not show conclusive results.
Differences and limitations (compared to FactoryBot)
If you are used to FactoryBot, be aware that there are quite a few differences in specifics.
General:
- The user (you) is responsible for loading forge definitions, there are no search paths. If ObjectForge is used in tests, it should be enough to add something like
Dir["spec/forges/**/*.rb].each { require _1 }to yourspec_helper.rb(orrails_helper.rb). Forgeyard.defineis the forge definition block, there is no separatefactoryblock.
Forge definition:
- Class specification for a forge is non-optional, there is no assumption about the class name.
- If the DSL block declares a block argument,
selfcontext is not changed, and DSL methods can't be called with an implicit receiver. - There is no forge inheritance or nesting.
Attributes:
- Currently, there is no concept of transient attributes. Attribute selection needs to be handled by the mold.
- There are no associations. If nested objects are required, they should be created and set in the block for the attribute.
Traits:
- Traits can't be defined inside of other traits.
- Traits can't be called from other traits. This may change in the future.
- There are no default traits.
Sequences:
- There is no explicit way to define shared sequences, but a freestanding
Sequencecan be created manually and passed intosequencecalls. - Sequences work with values implementing
#succ, not#next, expressly prohibitingEnumerator. This may be relaxed in the future.
Current and planned features (roadmap)
kanban
[✅ Done]
[FactoryBot-like DSL: attributes, traits, sequences]
[Independent forges]
[Independent forgeyards]
[Default global forgeyard]
[Thread-safe behavior]
[Tapping into built objects for post-processing]
[Custom builders / molds]
[Built-in Hash, Struct, Data builders / molds]
[Ability to replace resolver]
[After-build hook]
[⚗️ To do]
[Transient attributes / attribute filtering]
[❔ Maybe, maybe not]
[Calling traits from traits]
[Default traits]
[Forge inheritance]
[Premade performance forge: static DSL, epsilon resolver]
[Enumerator compatibility in sequences]
Development
After checking out the repo, run bundle install to install dependencies. If you will be running typing checks (RBS/Steep), also execute rbs collection install.
Then, run rake spec to run the tests, rake rubocop to lint code and check style compliance, rake rbs to validate signatures or just rake to do everything above. There is also rake steep to check typing, and rake docs to generate YARD documentation.
You can also run bin/console for an interactive prompt that will allow you to experiment, or bin/benchmark to run a benchmark script and generate a StackProf flamegraph.
To install this gem onto your local machine, run rake install. To release a new version, run rake version:{major|minor|patch}, and then run rake release, which will push git commits and the created tag, and push the .gem file to rubygems.org.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/trinistr/object_forge.
Checklist for a new or updated feature
- Running
rake specreports 100% coverage (unless it's impossible to achieve in one run). - Running
rake rubocopreports no offenses. - Running
rake steepreports no new warnings or errors. - Tests cover the behavior and its interactions. 100% coverage is not enough, as it does not guarantee that all code paths are tested.
- Documentation is up-to-date: generate it with
rake docsand read it. - "CHANGELOG.md" lists the change if it has impact on users.
- "README.md" is updated if the feature should be visible there, including the Kanban board.
License
The gem is available as open source under the terms of the MIT License, see LICENSE.txt.