Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
- '3.2.2'

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
Expand Down
1 change: 1 addition & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Metrics/BlockLength:
Enabled: false

Metrics/MethodLength:
Max: 15
AllowedMethods: [initialize, initialize_with]

Metrics/AbcSize:
Expand Down
30 changes: 27 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,33 @@
## [Unreleased]

* N/A

## [0.2.0] - 2026-02-19

### Changed
- `initialize_with` readers are now always defined, even if a method with the same name already exists. This makes `foo` consistently return the init-arg value.

### Breaking
- Previously, if a method `#foo` existed (on the class or an ancestor), the gem skipped defining the reader and logged a warning; callers had to use `@foo` to access the init-arg. Now the reader is defined and overrides the existing method.

### Added
- Optional override warnings in Rails development/test, or when logger level is `DEBUG`.

### Fixed
- No warning on Rails reload when the existing method was originally defined by this gem.
- No warning when a subclass re-declares an attribute already declared by an ancestor’s `initialize_with`.
- Duplicate common mutable default values (`Array`, `Hash`, `Set`, `String`) per instance when the caller omits that keyword, preventing accidental cross-instance mutation. Copy is shallow; caller-provided values are not duplicated.


## [0.1.1] - 2025-05-02
- Refactor internals
- [BUGFIX] Only trigger `attr_reader` creation on initial class load (vs on every call to `#new`)

### Changed
- Refactor internals.

### Fixed
- Only define `attr_reader`s on initial class setup (avoid re-defining on each call to `.new`).

## [0.1.0] - 2025-03-05
- Initial release

### Added
- Initial release.
2 changes: 1 addition & 1 deletion CODE_OF_CONDUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ This Code of Conduct applies within all community spaces, and also applies when

## Enforcement

Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at kali@teamshares.com. All complaints will be reviewed and investigated promptly and fairly.
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at oss@teamshares.com. All complaints will be reviewed and investigated promptly and fairly.

All community leaders are obligated to respect the privacy and security of the reporter of any incident.

Expand Down
2 changes: 1 addition & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ source "https://rubygems.org"
# Specify your gem's dependencies in declarative_initialization.gemspec
gemspec

gem "pry-byebug", "~> 3.11.0"
gem "pry-byebug", "~> 3.12.0"
gem "rake", "~> 13.0"
gem "rspec", "~> 3.0"
gem "rubocop", "~> 1.21"
197 changes: 133 additions & 64 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,106 +1,175 @@
# DeclarativeInitialization

Boilerplate slows down devs and irritates everyone, plus the added cruft makes it harder to scan for the actual logic in a given file.
Declare a class’s keyword inputs once and get a keyword-only `initialize` with assignments, readers, and helpful argument errors.

This is a small layer to support declarative initialization _specifically for simple keyword-based classes_.
- **Keyword-only initializer**: rejects positional args and unknown keywords
- **Assignments**: sets `@keyword` instance variables from declared inputs
- **Readers**: defines `attr_reader` for each input (and a `block` reader)
- **Defaults**: supports optional keywords with default values
- **No dependencies**: plain Ruby (\(>= 3.0\))

## Usage
## When to use it

Use this when you have small POROs that take keyword inputs and you’re tired of repeating the same initializer boilerplate:

- **Service / command objects** that take dependencies and parameters
- **Value objects** with a fixed set of attributes
- **Configuration objects** with a handful of optional flags
- **Components / presenters** that accept a stable set of inputs

If you need complex inheritance initialization, multiple initializer “shapes”, or highly dynamic defaults, a handwritten `initialize` may be clearer.

Given a standard ruby class like so:
## Installation

Add to your Gemfile:

```ruby
class SomeObject
def initialize(foo:, bar:, baz: "default value")
@foo = foo
@bar = bar
@baz = baz
end
gem "declarative_initialization"
```

Then install:

```bash
bundle install
```

In non-Bundler contexts, require it directly:

```ruby
require "declarative_initialization"
```

## Quick start

attr_reader :foo, :bar, :baz
```ruby
class UserGreeter
include DeclarativeInitialization

initialize_with :user

def call
"Hello, #{user.name}!"
end
end

UserGreeter.new(user: current_user).call
# => "Hello, Alice!"

UserGreeter.new
# ArgumentError: [UserGreeter] Missing keyword argument(s): user

UserGreeter.new(user: current_user, extra: true)
# ArgumentError: [UserGreeter] Unknown keyword argument(s): extra
```

With this library it can be simplified to:
## Usage

### Required vs optional keywords (defaults)

Declare required keywords as symbols, and optional keywords as keyword arguments:

```ruby
class SomeObject
class Search
include DeclarativeInitialization

initialize_with :foo, :bar, baz: "default value"
initialize_with :query, limit: 10, order: :desc

def call
results = perform_search(query).take(limit)
order == :desc ? results.reverse : results
end
end

Search.new(query: "ruby").call
Search.new(query: "ruby", limit: 50).call
```
## Quick note on naming

The gem name is `declarative_initialization` because there's already a very outdated gem claiming the `initialize_with` name.
### Post-initialize hook

We've set up an alias, however, so you can do either `include DeclarativeInitialization` _or_ `include InitializeWith`.
Pass a block to `initialize_with` to run code after assignments. The block runs in the instance context.

FWIW in practice at Teamshares we just `include DeclarativeInitialization` in the base class for all our View Components.
```ruby
class Rectangle
include DeclarativeInitialization

initialize_with :width, :height do
raise ArgumentError, "Dimensions must be positive" if width <= 0 || height <= 0
@area = width * height
end

attr_reader :area
end
```

### Custom logic
### Capturing a block passed to `.new`

Sometimes the existing `initialize` method also does other work, for instance setting initial values for additional instance variables that aren't passed in directly.
If the caller passes a block to `.new`, it’s stored in `@block` and available via the `block` reader.

We support that by passing an optional block to `initialize_with` -- for instance, in the example above if the original version also set `@bang = foo * bar`, we could support that by changing the updated version to:
```ruby
class Wrapper
include DeclarativeInitialization

```ruby
initialize_with :foo, :bar, baz: "default value" do
@bang = @foo * @bar
initialize_with :tag

def render
"<#{tag}>#{block&.call}</#{tag}>"
end
```
end

Wrapper.new(tag: "div") { "Content" }.render
# => "<div>Content</div>"
```

## Behavior notes / gotchas

### Edge cases
### Only keyword arguments are accepted

* Accepting a block: this is handled automatically -- if a block was provided to the Foo.new call, it'll be made available as `@block`/`attr_reader :block`
The generated initializer is keyword-only. Passing positional arguments raises an `ArgumentError`.

* If a method with same name already exists, we log a warning and do not create the `attr_reader`. In that case you'll need to reference the instance variable directly.
### Readers are public by default

* Because of this, best practice when referencing variables in the post-initialize block is to use `@foo` rather than relying on the `foo` attr_reader
Inputs are exposed with `attr_reader`. If you prefer private readers, make them private after the declaration:

* Due to ruby syntax limitations, we do not support referencing other fields directly in the declaration:
```ruby
class Example
include DeclarativeInitialization
initialize_with :user, admin: false

* Does _not_ work:
```ruby
initialize_with :user, company: user.employer
```
* Workaround:
```ruby
initialize_with :user, company: nil do
@company ||= @user.employer
end
```
private :user, :admin
end
```

* If using `initialize_with` on a subclass where the superclass defines `initialize`, we will _not_ automatically call `super`, because if we do we get this `RuntimeError`:
> implicit argument passing of super from method defined by define_method() is not supported. Specify all arguments explicitly.
### Defaults are literal values

* If you need to call `super` from the block passed into `initialize_with` (unusual edge case, subclass requires different arguments than parent):
Defaults are applied when the caller omits that keyword. For common mutable defaults (`Array`, `Hash`, `Set`, `String`), the value is duplicated per instance (shallow). If you need deeper setup (or derived values), use the post-initialize block.

* Does _not_ work (due to `instance_exec` changing execution context but _not_ the method lookup chain):
```ruby
initialize_with :foo do
super(bar: 123)
end
```
* Workaround _possible_ (but really, probably more understandable to just fall back to manually writing `def initialize`):
```ruby
initialize_with :foo do
parent_initialize = method(:initialize).super_method
parent_initialize.call(bar: 123)
end
```
### Method name conflicts

* If you find yourself backed into a weird corner, just use a plain ole `def initialize`! This library is meant to make the easy cases less work, but there's no requirement that you must use it for every super complex case you run into. :)
`initialize_with` defines readers for each declared input (and `block`). If a method with the same name already exists, it will be overridden.

## Development
In Rails development/test (or when your logger level allows it), the gem logs a warning when it overrides an existing method.

After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
### Referencing other inputs in defaults

To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
You can’t reference one declared input from another input’s default at declaration time:

## Contributing
```ruby
initialize_with :user, account: user.account # user is not available here
```

Bug reports and pull requests are welcome on GitHub at https://github.com/teamshares/declarative_initialization. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/teamshares/declarative_initialization/blob/main/CODE_OF_CONDUCT.md).
Use the post-initialize block instead:

## Code of Conduct
```ruby
initialize_with :user, account: nil do
@account ||= user.account
end
```

### Inheritance and `super`

`initialize_with` generates an `initialize` method. If a subclass calls `initialize_with`, it replaces the parent initializer and **does not call `super`**. Prefer a single initializer per hierarchy, or avoid this gem for complex inheritance chains.

## Contributing

Everyone interacting in the DeclarativeInitialization project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/teamshares/declarative_initialization/blob/main/CODE_OF_CONDUCT.md).
- **Source**: [teamshares/declarative_initialization](https://github.com/teamshares/declarative_initialization)
- **Code of conduct**: [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md)
4 changes: 4 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,7 @@ require "rubocop/rake_task"
RuboCop::RakeTask.new

task default: %i[spec rubocop]

# Require default to pass before release. This relies on the default gem release task
# (from bundler/gem_tasks) depending on "build"; default runs before build, so before push.
Rake::Task["build"].enhance([:default])
17 changes: 4 additions & 13 deletions lib/declarative_initialization.rb
Original file line number Diff line number Diff line change
@@ -1,23 +1,14 @@
# frozen_string_literal: true

require_relative "declarative_initialization/version"
require_relative "declarative_initialization/internal"
require_relative "declarative_initialization/class_methods"
require_relative "declarative_initialization/instance_methods"

module DeclarativeInitialization
def self.included(base)
base.class_eval do
include InstanceMethods
extend ClassMethods
end
base.extend ClassMethods
end
end

# Set up an alias so you can also do `include InitializeWith`
module InitializeWith
def self.included(base)
base.class_eval do
include DeclarativeInitialization
end
end
end
# Alias so you can also do `include InitializeWith`
InitializeWith = DeclarativeInitialization
Loading