Modeling business logic with ECS in Ruby
by Paweł Świątkowski
21 Mar 2023
This blog post explores the possibility of using Entity Component System architecture to model business logic in a “regular business application”, like the ones we usually work with. It’s based on a talk I gave a few months ago in a local Ruby users group. I’d like to stress that these are notes from an experiment, I’m not saying that you absolutely should use this technique in your main production application. In fact, you probably should not.
Let’s start!
What is ECS?
Entity Component System is an architecture coming from the game development industry. It is sometimes dubbed an alternative to OOP. It concentrates on grouping traits and behaviours instead of putting things into (often fake) hierarchiesThis is one of the main “accusations” of a game dev world towards OOP - that it’s hard to build a hierarchy upfront, that it’s difficult to change once you have it and that it often feels not natural., like in class-based OOP. Some game developers also swear that it better supports the chaotic process of making games, where sometimes you need to add or adjust a big thing just before the release.
There are 3 main building blocks of ECS architecture:
- Entity – it is just “something” having an identifier and some components attached
- Component – just data, without any behaviour
- System – a behaviour for entities containing specific components
In a typical video game, let’s say Bomberman, we could have entities such as: player, wall, enemy, explosion, bomb. Next to them, we could have following components: position, animation, health points, movement (speed, direction), susceptibility to gravity. And finally, some systems:
- change animation frame
- update position
- subtract health points from entities in the blast radius
- kill entites with zero HP
- count down by one to bomb explosion
- check if all enemies are dead
- check if the player is alive
ECS in Ruby
I found a couple of Ruby libraries to model with ECS. Here are some of them:
- Baku – unmaintained (most commits from 6 years ago), poorly documented, created for Gosu framework
- Chione – hard to tell anything useful about it for me, only contains API docs
- FelECS – it was very new when I gave the talk initially, looked promising
- Draco – ECS for DragonRuby, which I eventually picked to write my examples; today it seems a bit abandoned
Sample code with Draco
# entity
player = Draco::Entity.new
# component
class Health < Draco::Component
attribute :value
end
player.components << Health.new(value: 3)
# we can also define entity using a class
class Player < Draco::Entity
component Position, x: 1, y: 1
component Tag(:visible)
component Health
end
player = Player.new
# finally, a system
class RenderSpriteSystem < Draco::System
filter Tag(:visible), Position, Sprite
def tick(args)
camera = world.filter([Camera]).first
sprites = entities.select { |e| entity_in_camera?(e, camera) }.map do |entity|
{
x: entity.position.x - camera.position.x,
y: entity.position.y - camera.position.y,
w: entity.sprite.w,
h: entity.sprite.h,
path: entity.sprite.path
}
end
args.outputs.sprites << sprites
end
def entity_in_camera?(entity, camera)
# ...
end
end
Okay, this is all very nice, but how does it relate to our “serious” web application? Let’s see how we could apply it to an e-commerce project…
E-commerce with ECS
The heart of every e-commerce project is an orderOr a basket, which is often just an order in draft state. But let’s not go into this discussion right now…. Let’s see what an order may contain:
- Obviously, a list of products
- Coupons or vouchers
- Discounts (such as “buy 4, get 1 free”)
- Shipping
- Packaging
- First-purchase discount
The “things” above have the following attributes / propertiesOf course, not every item from the first list has all the attributes from the second one, but most attributes are shared among at least two items.:
- Price
- Tax
- Quantity
- Weight
- Restrictions (e.g. you can only buy alcohol if you are 18)
As you have probably guessed, the first list are our entities, the second are components. Now, it’s time for systems:
- Apply discounts
- Calculate final price
- Calculate taxes
- Calculate total mass
- Check if you can ship to the person who ordered
Let’s take calculating the price. In the “classic approach” it would probably look more or less like this:
def calculate_total_price
calculate_products + # products.each { |p| p.amount * p.unit_price }
calculate_shipping_price + # shipping.pickup? ? 0 : shipping.price
calculate_packaging +
apply_vouchers +
apply_applicable_discounts
end
Let’s have a look at the ECS counterpart to this code:
Components:
require './draco'
class Description < Draco::Component
attribute :name
end
class Price < Draco::Component
attribute :value
end
class Weight < Draco::Component
attribute :value
end
class Quantity < Draco::Component
attribute :value
end
class Tax < Draco::Component
attribute :name
attribute :value
end
A product entity:
product = Draco::Entity.new
product.components << Description.new(name: "Sugar")
product.components << Quantity.new(value: 2)
product.components << Weight.new(value: 1)
product.components << Price.new(value: 12.5)
product.components << Tax.new(name: "Federal 7%", value: 7)
product.components << Tax.new(name: "State 7%", value: 7)
And finally, the system:
class CalculateTotalPrice < Draco::System
filter Price
# tick method gives away that we are taking a library designed for games
def tick(summary)
price = 0
entities.each do |e|
q = e.respond_to?(:quantity) ? e.quantity.value : 1
price += e.price.value * q
end
summary.total_price = price
end
end
Now let’s put that all together:
summary = CartSummary.new
cart = Draco::World.new
cart.entities << product
cart.systems << CalculateTotalPrice
cart.tick(summary)
summary.total_price # => 12.55
Cool. We have done a lot of work just to not write a simple products.each { |p| p.amount * p.unit_price }
, right?
Of course, using ECS to just sum up some prices would be a huge waste of energy. But remember what I quoted before, about being in line with a chaotic development environment? Suppose you have your price calculation logic in place, and you think you are almost ready to release. But soon it turns out you cannot release without some other additions. And that’s where ECS is supposed to shine.
So let’s see some magic happen.
Magic: The Extending
“We absolutely need vouchers!”, shouts the PM.
Okay, here is vouchers implementationPrice calculation will still work, having a voucher will substract its value from the final price. And other systems, not relying on the price, will simply skip vouchers - for example the one to calculate the total weight):
voucher = Draco::Entity.new
voucher.components << Price.new(value: -10)
cart.entities << voucher
“And shipping! We forgot about shipping!”
Sure.
shipping = Draco::Entity.new
shipping.components << Price.new(value: 15.99)
shipping.components << Tax.new(value: 23)
shipping.components << Tag(:shipping)
cart.entities << shipping
“But what about the pick-up in person?!”
Okay.
pickup = Draco::Entity.new
pickup.components << PickupInfo.new(location: "main-square")
pickup.components << Tag(:shipping)
cart.entities << pickup
“Make sure that each order has either shipping or pick-up!”
class CheckShipping < Draco::System
filter Tag(:shipping)
def tick(summary)
if entities.length != 1
raise NoShippingException
end
end
end
“Hello, here’s Irene from legal. Did you make sure that you cannot sell alcohol to underage customers?”
class RestrictAlcoholSale < Draco::System
filter Tag(:alcohol)
def tick(summary)
customer = summary.customer
if entities.length > 0 && customer.under_18?
raise AgeRestrictionsViolated.new(entities)
end
end
end
beer.components << Tag(:alcohol)
cart.systems << RestrictAlcoholSale
As you can see, the system built this way is extremely open for extension, while you don’t need to modify the coreIf this rings a bell, it’s because it should.. We added vouchers, shipping, and pickup without touching the code calculating the price - it just kept working. Similarly, we added a system for restricting the alcohol sale and all we needed to make it work was to add :alcohol
tag component to some products.
Some final comments
- ECS proved (for me) to be very elastic solution, with which we can add new things without having to adjust already existing functionality; and without the fear it will break. I think it might be especially useful in dynamic environments where requirements change often and we are still discovering a lot about the domain.
- Existing implementations in Ruby are very game-oriented (unsurprisingly) and it sometimes shows.
- Systems in ECS are designed to be run in parallel. This might not be always viable for “regular” (non-game) applications, but some parts of it can for sure run independently of othersFor example: age verification does not depend on price calculation in any way. But other checks might: first-time customers cannot buy for more than $1000.
- Of course, there’s a whole story about how to serialize it and store it in the database. It should be possible, but requires separate investigation.
To summarize, I think it was an interesting experiment and learning opportunity to see how other people are dealing with their stuff.