Painting in layers: what Vermeer taught my recommendation service
Colour harmony, single responsibility, Vermeer's window, and a Ruby service built up in three coats like an oil painting.
Every craft has rules, and there’s usually a good reason for them.
Look at a painting where the colours clash and you’ll feel uneasy before you can explain why. Over centuries, painters developed principles that make work feel balanced: symmetry, colour harmony on the colour wheel, perspective. Not to limit creativity, but to give it something to push against.
Code has rules too. And just like painters, we get creative inside them.
one responsibility per brushstroke
Take the Single Responsibility Principle from SOLID: each class should have one clear job that makes sense on its own. In our gallery app, Artist only knows about artists, with validations that belong to an artist and nothing else.
class Artist < ApplicationRecord
has_many :artworks
validates :name, presence: true, uniqueness: true
validates :nationality, presence: true
endArtwork gets the same treatment. Each class is one well-placed element in the composition.
Vermeer’s window
The Milkmaid. Johannes Vermeer, c. 1658.
Look at The Milkmaid. Half of her body is lit, the other half sits in shadow. The table is lit from one side too. Why? Because of the window on the left. Vermeer didn’t only paint his subject. He painted the light source that affects everything in the room.
Girl with a Pearl Earring. Johannes Vermeer, c. 1665.
Same in Girl with a Pearl Earring: half of her face in light, half in shadow. Vermeer always considered the context, not just the subject.
In code, we have to do the same and think about the outside forces that will shape our work over time.
the recommendation problem
Say I want to recommend artworks. Recommendations could be based on the artist, the movement, the period. My first version fetched everything related and returned it in one pile.
That was chaos. The user had no idea why something was recommended. So I split recommendations by type:
class ArtworkRecommendationService
def self.call(artwork, type)
scope =
case type
when :artist then Artwork.where(artist: artwork.artist)
when :movement then Artwork.where(movement: artwork.movement)
when :period then Artwork.where(year: (artwork.year - 10)..(artwork.year + 10))
else raise ArgumentError, "unknown recommendation type: #{type}"
end
scope.where.not(id: artwork.id)
end
endClearer. But here’s the window light I almost ignored: the artwork model will grow. Twenty attributes, thirty. Every new recommendation type means opening this service and editing it again. That’s the context, and the service isn’t built for it.
an oil painting is built in layers
A painting that looks effortless was rarely done in one go. Oil painters work in layers: a base, then shapes, then light, then detail. So let’s paint this service in layers too.
First coat: one class per strategy. Each recommendation type gets its own class, all inheriting from a parent that defines find.
class RecommendationStrategy
def find(artwork) = raise(NotImplementedError)
end
class ArtistRecommendation < RecommendationStrategy
def find(artwork)
Artwork.where(artist: artwork.artist).where.not(id: artwork.id)
end
endSecond coat: a registry. Let’s make it more Ruby. Strategies are registered by name, and the service looks them up instead of hardcoding a case.
class RecommendationStrategy
REGISTRY = {}
def self.register(name, klass) = REGISTRY[name] = klass
end
RecommendationStrategy.register(:artist, ArtistRecommendation)Adding a new strategy no longer means touching the service. That’s the Open/Closed Principle, arriving almost by itself.
Third coat: let inheritance do the work. Ruby gives us an inherited hook, so a strategy can register itself the moment it’s defined.
class RecommendationStrategy
REGISTRY = {}
def self.inherited(subclass)
super
key = subclass.name.delete_suffix("Recommendation").downcase.to_sym
REGISTRY[key] = subclass
end
def find(artwork) = raise(NotImplementedError)
end
class ArtworkRecommendationService
def self.call(artwork, type)
strategy = RecommendationStrategy::REGISTRY.fetch(type) do
raise ArgumentError, "unknown recommendation type: #{type}"
end
strategy.new.find(artwork)
end
endNow adding a MovementRecommendation is just… writing MovementRecommendation. No service changes, no registration line.
We could keep layering forever. But like any painting, at some point you step back, look at the whole canvas, and decide it’s done.
Next entry, let’s zoom all the way out: does code have art movements too?