Avoid the general case

This year, Neuvents turns 10. We've been running Ruby events for 10 freaking years. Congratulate us – we deserve it! But as much as I'd like to ruminate, this isn't an anniversary post. This is a hot take about software architecture.

image 1

The Setup

Neuvents started in 2016 when we needed a company to sell EuRuKo tickets. It was founded by Svetlozar Todorov, Svetlozar Mihaylov, Vestimir Markov, and me. We called it "EuRuKo 2016 LTD" – very creative. After the conference, we renamed it to "Neuvents". I don't like to brag (I do), but I've come up with some good names: Balkan Ruby, Ruby Banitsa, partial :: Conf, Sponsor Conf... but Neuvents? Nobody can type it, including me.

We used a 3rd-party system to sell tickets, but created invoices manually. Svetlozar Mihaylov diligently kept records in a small Rails app we called "Фактурник". Factorio, in English. We're filing the trademark, don't worry.

When we brought Balkan Ruby back in 2024, I started a brand-new system. A static Rails app with a Stripe Link for payments. About a month later, attendees started asking: "Where's my ticket?". Payment wasn't enough – they needed a token for their money. So we built a ticketing system. Then businesses asked for invoices, so we built them by hand. And messed them up. I had to automate.

The PDF problem

I was on Heroku and didn't want to pay for storage. I needed a PDF solution that doesn't save documents. This ruled out the "spin up a headless browser and screenshot HTML" approach. Simple to implement, but terribly inefficient – you need a server powerful enough to run a whole browser JUST to print HTML to PDF. Then you face storage: where to save them, how to name them, how to update them.

Wrong problem to solve.

We render HTML in milliseconds. Why not PDFs? We can, but we have to generate them natively – somehow a rare skill. So I built a document system using Prawn that renders invoice PDFs on-demand in milliseconds.

Bulgaria adopted the Euro in 2026, btw. But before that, we used the Lev (BGN), so we needed two documents per transaction – one in Bulgarian/BGN, one in English/EUR. No problem for on-demand generation. And because we generate on demand, we can update documents, fix mistakes, and keep them in sync. Useful in the early days when we were figuring things out. Did you know a Bulgarian invoice is only valid if it says "ОРИГИНАЛ" (ORIGINAL) somewhere on the document? Neither did we.

The issuer problem

For the first invoices, I stored only what was needed in the database and hard-coded everything else. Company name, address, CEO – all hard-coded. This was (and still is) the right decision.

Then, in March 2024, things changed. Svetlio Mihaylov had smaller kids and no time to run the company, so I took over as CEO. Now I had different data, but couldn't just change the issuer – old documents are generated on-demand. Time to store the issuer in the database? Data migrations?

Nope:

def genadi_ceo? = invoice.created_at.after? GENADI_AS_CEO_DATE

A condition based on a date. No migrations. No new columns. Historical data preserved. Perfection.

Doubling down

We solved ticket invoicing, but still needed invoices for other company activities. And we wanted to import all historical accounting data from past events.

Same issue: we had one rename (EuRuKo 2016 → Neuvents) and three CEO changes (Svetlio Todorov → Svetlio Mihaylov → me) throughout the years.

Did that teach me to finally put the issuer in the database? No.

For a 4.5MB database, we'd sync about 350KB for issuer data alone. Company changes are rare. When they happen, add a condition. So instead of "cleaning up the hack", I doubled down:

class Issuer
  PERIODS = [
    { key: :euruko_2016, duration: Date.new(2016, 1, 1)..Date.new(2017, 7, 9) },
    { key: :neuvents_todorov, duration: Date.new(2017, 7, 10)..Date.new(2019, 6, 2) },
    { key: :neuvents_mihaylov, duration: Date.new(2019, 6, 3)..Date.new(2024, 3, 11) },
    { key: :neuvents_genadi, duration: Date.new(2024, 3, 12).. },
  ]

  def initialize(date:, locale:)
    @key = PERIODS.find { it[:duration].cover?(date) }.fetch(:key)
    @locale = locale
  end

  def company_name = t(:company_name)
  def address = t(:address)
  def country = t(:country)
  def company_id = t(:company_id)
  def vat_id = t(:vat_id)
  def ceo = t(:ceo)

  private

  def t(attr) = I18n.t("invoicing.issuers.#{@key}.#{attr}", locale: @locale)
end

image 3

The HOT TAKE

The junior in me would've been mad:

  • "But this is not how we do things!"
  • "THERE IS AN IF!?" (the worst, I know)
  • "What if it changes again?"
  • "What if I need to issue an invoice from another company?"

Would I really?

Why sink time building a general solution for the off-chance I need it? I could have skipped importing historical data altogether. Would've been fine.

Here's the thing – I didn't notice the company differences during the initial import. I noticed them afterwards. And fixed them WITHOUT ANY DATA MIGRATIONS. That's nice.

image 2

Solve for the specific. It's not a hack. It's the right solution. The beautiful solution.

Be careful with general solutions. They cost time, add needless complexity, and solve problems you might never have. And when requirements change? Change. But don't guess ahead of time.

But that's my take – I'm sure you have yours. Share it with us! Our CFP is still open at Complain I mean Balkan Ruby dot com.

Genadi Samokovarov

Genadi Samokovarov

Organizer

Sponsors

By sponsoring Balkan Ruby, you are helping us make a great event while promoting your brand to the passionate Ruby developers in Bulgaria, the Balkans, and beyond!

Sponsor us

Tickets

Support Balkan Ruby 2026 by purchasing a ticket. ❤️

Buy a ticket

Balkan Support

Offline

This widget is a joke! For real support, contact us at hi@balkanruby.com.