Doing more on reads vs writes
… and check why 5600+ Rails engineers read also this
Doing more on reads vs writes
What do you like more in your application? Handling complexity when reading the data or when updating it?
Imagine you have a model such as Product
with three potential date attributes:
publication_on
- Nominal date of publication. This date is primarily used for planning, promotion and other business process purposes, and is not necessarily the first date for retail sales or fulfillment of pre-orders.preorder_on
- Preorder embargo date. Earliest date a retail ‘preorder’ can be placed (in the market)announce_on
- Public announcement date. Date when a new product may be announced to the general public.
Also, three additional business rules:
- When
preorder_on
is not explicitly provided,publication_on
date should be used instead to determine if a product can be ordered. - When
announce_on
is not explicitly provided,preorder_on
date should be used instead to determine if a product can be viewed. Ifpreorder_on
is not provided as well, thenpublication_on
should be used instead. announce_on
<=preorder_on
<=publication_on
What this means is that a product can be:
- announced and visible
- preordered and thus buyable
- published
Of course, our e-commerce publishing system would like to query for products that can be displayed or purchased. There are multiple ways to implement such a solution. I am going to present two of them.
Typical
I would say the most common solution deals with these business rules when reading the data.
class Product < ApplicationRecord
def self.visible(at = Date.current)
where("
announce_on <= :at
OR (
announce_on IS NULL AND
preorder_on <= :at
) OR (
announce_on IS NULL AND
publication_on IS NULL AND
release_on <= :at
)
", at: at)
end
def self.buyable(at = Date.current)
where("
preorder_on <= :at
) OR (
preorder_on IS NULL AND
publication_on <= :at
)
", at: at)
end
def visible?(at = Date.current)
(announce_on && announce_on <= at) ||
(preorder_on && preorder_on <= at) ||
publication_on <= at
end
def buyable?(at = Date.current)
(preorder_on && preorder_on <= at) ||
publication_on <= at
end
end
I would say this is a typical UI-driven development. The user can provide three different fields. Let’s have three different fields for storing these values. Nothing happens here on writes. Value A is stored as A. That’s it. The whole presented logic is verified when reading the data, when querying it. If you send your products to ElasticSearch or Algolia, you need to repeat similar logic in those queries as well. However, it is simple, and it works.
Let’s try something different.
More complex writes, easier reads
In this solution whenever we change one of our three main attributes which can be provided from the UI, we immediately recompute their effective, derived values.
class Product < ApplicationRecord
def self.visible(at = Date.current)
where("announce_on_computed <= ?", at)
end
def self.buyable(at = Date.current)
where("preorder_on_computed <= ?", at)
end
def visible?(at = Date.current)
announce_on_computed <= at
end
def buyable?(at = Date.current)
preorder_on_computed <= at
end
def publication_on=(val)
super.tap{ recompute_dependent_columns }
end
def preorder_on=(val)
super.tap{ recompute_dependent_columns }
end
def announce_on=(val)
super.tap{ recompute_dependent_columns }
end
private
def recompute_dependent_columns
self.preorder_on_computed = preorder_on || publication_on
self.announce_on_computed = announce_on || preorder_on_computed
end
end
So instead of having more complex reads, we have more complex writes. The reads are now stupidly simple. Also, you could send those derived dates to Elastic Search and the queries to it would be equally simple. While preorder_on
and announce_on
might be nil
, their computed counterparts should not be (assuming a validation on publication_on
).
The downside of this solution is that in many places you might need to remember to use preorder_on_computed
instead of preorder_on
. It could be tempting to reverse the nomenclature and use preorder_on_provided
or a similar, longer name for the value coming from the UI. And to reserve preorder_on
for the precomputed, not-null value which should be used for queries. Whichever way you go, make sure to communicate the pattern that you use with your team.
Explicit transition
There is potentially an even simpler solution lurking here. Instead of remembering preorder_on_computed
and announce_on_computed
we would add booleans such as is_visible
and is_buyable
. Once a day (think cron job or a scheduler) we would query for all products which should become visible
or buyable
today and we would switch their booleans from true
to false
. Similarly, we would need to do it when the user updates one of our three main attributes.
class Product < ApplicationRecord
# executed daily
def self.recompute!
where("
announce_on = :today OR
preorder_on = :today OR
publication_on = :today
", today: Date.current).find_each do |p|
p.recompute_dependent_columns
p.save!
end
end
def self.visible
where(is_visible: true)
end
def self.buyable
where(is_buyable: true)
end
def visible?
is_visible?
end
def buyable?
is_buyable?
end
def publication_on=(val)
super.tap{ recompute_dependent_columns }
end
def preorder_on=(val)
super.tap{ recompute_dependent_columns }
end
def announce_on=(val)
super.tap{ recompute_dependent_columns }
end
def recompute_dependent_columns
self.is_buyable = [
preorder_on,
publication_on
].reject(&:nil).min <= Date.current
self.is_visible = [
announce_on,
preorder_on,
publication_on
].reject(&:nil).min <= Date.current
end
end
Depending on your preferences this might seem even easier than the previous solution. Or, it might look ugly or like an over-kill.
There is, however, one potential benefit lurking here. With some slight modifications, we could detect if the product was just announced, hidden, pre-orders opened or closed. In such case, we could publish an appropriate domain event on our message bus. Thus making it an explicit event in our system that something important happened; potentially notifying other bounded contexts about a significant fact from our domain.
It might be a critical reflection that there is something like a Calendar Bounded Context in your application. The fact that time passed, there is a new day, new business day, new week, new month, new year, etc. is a crucial event that triggers state changes in your system.
Going even further in that direction, we could split our Product into two classes/models:
- write model responsible for verifying business rules
- read model for querying
Our write model does not need is_buyable
or is_visible
at all. But, it’s very beneficial for the read-model which can live as a separate table in the SQL DB, or it could be in the already mentioned Elastic Search.
However, usually when working with ActiveRecord
, we mix those two models together. In more complex apps, it might be a good idea to separate them.
12-weeks Rails Events video class
Subscribe to our mailing list to be notified about our upcoming 12-weeks Rails Events video class.
You might also enjoy reading:
- Ruby Event Store - use without Rails - did you know you can use RailsEventStore without Rails by going with RubyEventStore :)
- When DDD clicked for me - It took me quite a time to grasp the concepts from DDD community and apply them in our Rails projects. This is a story of one of such “aha” moments.
- Conditionality is filtering. Don’t filter control flow, filter data. - a short example for how to start getting rid of if-statements in your code.