Overall, I’m really happy with your general direction here. I think this plan will be a major step forward in advancing the state of the art of persistence that has been pretty stagnant in Rails.
DATABASE_URL
A slice may not want to use the same database as the rest of the app. If the slice has its own distinct
settings.database_url
, then that URL will be used instead of the app-level database URL. TheENV
will also be checked for a slice-specificENV["SLICE_NAME__DATABASE_URL"]
(e.g."BILLING__DATABASE_URL"
for a"billing"
slice).
Slice isolation in development is tricky, because for me it entails a departure from the production environment. In production, each slice is running in an isolated pod so they can just use DATABASE_URL
as usual. So essentially what we’re doing is developing the app as a modular monolith and deploying it as isolated services.
In development, I have multiple slice db configurations in place so that I can develop each slice without having to adjust my environment variables around. This problem is handled by SLICE_NAME__DATABASE_URL
.
An additional problem we ran into with this pattern is dealing with test databases. We ended up implementing an optional config/database.yaml
file that was instantiated as Persistence::Specification
and accounted for changing the connection to the test variant.
def test_override(uri)
dbname = uri.path.chomp("/")
if dbname.end_with?("_dev", "_development")
dbname.sub(/_dev(elopment)?$/, "_test")
else
"#{dbname.chomp("_test")}_test"
end
end
def coalesce_database_url(slice)
if slice.settings.respond_to?(:database_url)
slice.settings.database_url
elsif slice.app != slice
coalesce_database_url(slice.app)
else
ENV.fetch("DATABASE_URL")
end
end
# Final URI accounting for all overrides
#
# 1. Start with config/database.yaml URI
# 2. Fall back to DATABASE_URL
# 3. Change DB name for test environment
# 4. Apply any component overrides
#
# @return [URI::Generic]
def to_uri
uri = current_spec.fetch(:uri) { coalesce_database_url(@slice) }.then { URI(_1) }
if current_spec in { database: }
uri.path = "/#{database}"
end
if uri.path.chomp("/").empty?
uri.path = "/#{@slice.app.slice_name}_#{Hanami.env}"
end
uri.path = test_override(uri) if Hanami.env?(:test)
%i[hostname port scheme user password].each do |component|
if current_spec.key?(component)
uri.public_send "#{component}=", current_spec[component]
end
end
uri
end
This allows developers to account for individual variation in their local DB without having to construct a custom URI. The overrides are nice-to-have but the motivation is the test_override
logic.
Our database.yaml
is strictly development-only and not source controlled, although we also generate one inside docker-compose to help with CI.
Relations
As part of with ROM setup above, all the relations in
relations/
will be registered directly in the app/slice container. So e.g.app/relations/cafes.rb
will be available asapp["relations.cafes"]
.
This is a good idea, one that I initially didn’t do but ended up adding it for the reasons you state: accessing them off of db.rom
is annoying.
My persistence provider does
config.auto_registration(slice.root)
register "persistence.rom", ROM.container(config)
register("relations", memoize: true) { target["persistence.rom"].relations }
Returning to your Principle #1, “It’s just ROM”, this means that relation helpers will be written using a combination of rom-sql
’s relational syntax with Sequel as an escape hatch.
This was a bit of an uphill struggle for my team, as nobody was deeply familiar with Sequel and ROM is under-documented.
I think Hanami should make an effort to document not just the simple cases of doing simple CRUD queries, but provide some insight into how to do more complex things as well, like writing a CTE.
Not having the long history of Rails’ query syntax being Q&A’d for decades is a disadvantage, but I’d say that the flexibility to use advanced PostgreSQL features is a major advantage, and one of the reasons I chose it.
I’m willing to help out on the documentation front here.
Repositories
I’ve moved away from Rails’ organizational structure over time, preferring to organize objects by business domain instead. I have observed that creating explicit directories for certain object patterns tends to discourage people from filling in blanks of whats missing, and instead shoehorning logic into places it doesn’t really belong.
But, I think the primary reason why you’re going in this direction for Repos is that you’re planning to build your own registration logic, and that would be significantly harder to do if they’re not located in a predictable place.
In my project, it would be named something more like app/cafes/repo.rb
, and if I need to interact with multiple repos I will just rename them to be more explicit. I also find API stutter like app/repos/cafe_repo.rb
kind of annoying, but I understand your motivation for doing that. Renaming the included dep can’t be the default posture.
Changesets & Mappers
I wrote one custom Changeset to implement upsert on a relation, these and Mappers are under-documented in ROM.
Mappers are unfortunate, being derived from transproc, which is deprecated, and having a very different interface from dry-transformer. Perhaps these should be deemphasized until ROM can be updated to incorporate the Dry version.
On the other hand, even dry-transformer is pretty hard to use. There’s room for improvement here that’s certainly out of scope for 2.2
Provider Customization
Is configure_provider
a new concept? I don’t recognize it. This is very important, due to the way ROM (and Sequel) do extensions.
A sampling of things I’m doing:
config.plugin(:sql, relations: :instrumentation) do |plugin_config|
plugin_config.notifications = target[:notifications]
end
config.gateways.each do |(_, gw)|
gw.connection.pool.connection_validation_timeout = -1
end
config.plugin(:sql, relations: :auto_restrictions)
config.plugin(:sql, relations: :pick)
I think that last one is an extension I wrote to port Rail’s pick
feature.
MyApp::Structs
This is another case where it makes sense to have a dedicated directory because contrary to the majority of the app logic, these entities should not be auto-registered into the container, because it’s ROM’s job to hydrate them.
I prefer to use MyApp::Entities
because it’s not the fact they they’re a struct that is actually important; they are DB entities that happen to be structs. This might seem nit-picky, and I agree it sort of is, but I try where I can to discourage thinking about objects in terms of what they are rather than what they do.
There is also the potential confusion here between a Ruby Struct
, a Dry::Struct
, and a ROM::Struct
, all of which are different things