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.

end of the article

Tags: architecture

This article was written by me – Paweł Świątkowski – on 21 Mar 2023. I'm on Fediverse (Ruby-flavoured account, Elixir-flavoured account). Let's talk.