Upgrade to Pro — share decks privately, control downloads, hide ads and more …

One engineer company with Ruby on Rails

One engineer company with Ruby on Rails

Radoslav Stankov

April 26, 2024
Tweet

More Decks by Radoslav Stankov

Other Decks in Technology

Transcript

  1. !

  2. ☕ coffee machine not working % unreproducible bug What is

    the most scary thing for a developer? #
  3. ☕ coffee machine not working % unreproducible bug & PM

    saying change should be easy! What is the most scary thing for a developer? #
  4. ☕ coffee machine not working % unreproducible bug & PM

    saying change should be easy! ☎ someone calling on the phone What is the most scary thing for a developer? #
  5. ☕ coffee machine not working % unreproducible bug & PM

    saying change should be easy! ☎ someone calling on the phone What is the most scary thing for a developer? #
  6. Product-market fit is the point at which you have identified

    the best target industries, buyers and use cases for your product. Sales become repeatable and scalable. - Product-Market Fit
  7. Product-market fit is the point at which you have identified

    the best target industries, buyers and use cases for your product. Sales become repeatable and scalable. - Product-Market Fit
  8. You will likely have to split your main market into

    many segments and focus on them individually.
 So, the product-market fit will be a continuum of discovering a repeatable sales process for each segment. - Product-Market Fit
  9. ❌ Started with individual facility managers (B2C) 1 Pivoted to

    facility management companies (B2B) / Product journey
  10. ❌ Started with individual facility managers (B2C) 1 Pivoted to

    facility management companies (B2B) 2 Non consumption companies / Product journey
  11. ❌ Started with individual facility managers (B2C) 1 Pivoted to

    facility management companies (B2B) 2 Non consumption companies 3 Construction companies / Product journey
  12. ❌ Started with individual facility managers (B2C) 1 Pivoted to

    facility management companies (B2B) 2 Non consumption companies 3 Construction companies 4 Complex buildings / Product journey
  13. ❌ Started with individual facility managers (B2C) 1 Pivoted to

    facility management companies (B2B) 2 Non consumption companies 3 Construction companies 4 Complex buildings 5 New facility management companies / Product journey
  14. ❌ Started with individual facility managers (B2C) 1 Pivoted to

    facility management companies (B2B) 2 Non consumption companies 3 Construction companies 4 Complex buildings 5 New facility management companies ⏫ Target competitors / Product journey
  15. ❌ Started with individual facility managers (B2C) 1 Pivoted to

    facility management companies (B2B) 2 Non consumption companies 3 Construction companies 4 Complex buildings 5 New facility management companies ⏫ Target competitors 7 Growing companies / Product journey
  16. ❌ Started with individual facility managers (B2C) 1 Pivoted to

    facility management companies (B2B) 2 Non consumption companies 3 Construction companies 4 Complex buildings 5 New facility management companies ⏫ Target competitors 7 Growing companies 8 Stable companies 9 / Product journey
  17. ❌ Wrong names in database tables ❌ Too traditional User

    model ❌ Started with individual facility managers (B2C) ❌ Kicking off a service marketplace on top of mobile app ❌ Trying to expand to Spain ❌ Mistakes
  18. : Rado's tips "Find one customer and build around them.

    Target non-consumption first. Then, find more customers like them."
  19. "As a developer, be involved in sales, support, and onboarding

    of each new category of customers." : Rado's tips
  20. "Know the reasons why someone will not use your product.

    Eliminate those reasons -" : Rado's tips
  21. module SpecialCases extend self ACCOUNT_CUSTOMER1 = 1 ACCOUNT_CUSTOMER2 = 2

    ACCOUNT_CUSTOMER3 = 3 def disable_payment_document?(payment) # ... end def receipt_for_deposit_payments?(payment) # ... end def invoice_prefix(account) # ... end def receipt_prefix(account) # ... end def invoice_numbering_changed?(account) # ... end def proforma_title(account)
  22. ; What to work on next? < Reduce churn =

    Bring more customers > Reduce support load ? Quality of life - for customers, for me (DX)
  23. ; What to work on next? @ Fast track %

    Critical bugs ☀ 10 minute features or fixes
  24. ; What to work on next? B Friday (dedicated 2~3h)

    C Fix exceptions / bugs D Bump dependancies E Pay tech depth
  25. ; What to work on next? < Reduce churn =

    Bring more customers > Reduce support load ? Quality of life - for customers, for me (DX) @ Fast track % Critical bugs ☀ 10 minute features or fixes B Friday (dedicated 2~3h) C Fix exceptions / bugs D Bump dependancies E Pay tech depth
  26. F Friday all-hands (петъчната оперативка G) • Every Friday afternoon

    (~2-4h) • Keep company in sync and make decisions • Set goal for next week • Required for everybody • Share company CORE and HEALTH metrics • Share progress of product and support • Support shares common issues • Discussion on major issues • What surprises we encountered • What are the biggest blockers we are facing • sometime brainstorm
  27. - one developer (me) H - management portal I -

    implement a mobile app J - for iOS K / Android L
  28. Building mapping M Mobile app J Issue tracker C Employees

    N Bulletin board ; Notifications O Taxation E Contacts P Omni search Q Reporting R Printing S Bookkeeping T Voting U Financials V Homebook W Debtors X Bulk operations ⚙ Messaging Z Calendar [ Funds \ ePay / EasyPay ] Bank imports ^ Invoicing _ Warranty Issues `
 Technicians D
 Individual accounts a Business accounts M Audit logs /
 Archiving b Trials c Demo d
 I18n G e f
 Marketing site g
  29. 563 routes on web 26 screens on mobile app 105

    active record models 2176 tests, running for ~3 minutes 3 languages - bg G en f es e I Code metrics
  30. j Make common operations easy k Reduce indirection l Use

    as much vanilla Rails as possible m Have good e2e test coverage n Avoid writing JavaScript and CSS o Internationalize from day 0
  31. gem 'annotate' # == Schema Information # # Table name:

    buildings_print_templates # # id :bigint(8) not null, primary key # format :string not null # name :string not null # options :jsonb not null # created_at :datetime not null # updated_at :datetime not null # account_id :bigint(8) not null # user_id :bigint(8) # # Indexes # # index_buildings_print_templates_on_account_id_and_name (account_id,name) UNIQUE # index_buildings_print_templates_on_user_id (user_id) # # Foreign Keys # # fk_rails_... (account_id => accounts.id) # fk_rails_... (user_id => users.id) # class Buildings::PrintTemplate < ApplicationRecord
  32. gem 'counter_culture' class Apartment < ApplicationRecord belongs :building # will

    track active and archived apartments separately counter_culture :building, column_name: -> { _1.active? ? :apartments_count : :archived_apartments_count } # can do this for the whole account counter_culture %i(building account), column_name: -> { _1.building.active? && _1.active? ? :apartments_count : :arch # can track a sum of a columns counter_culture :building, column_name: :unpaid_taxes_amount, delta_magnitude: -> { _1.unpaid_taxes_amount }
 counter_culture :building, column_name: :deposit_amount, delta_magnitude: -> { _1.deposit_amount } end
  33. <%= render FieldsetComponent.new(fieldset, title: :bank_account) do %> <%= form.input :bank_account_bank

    %> <%= form.input :bank_account_iban %> <%= form.input :bank_account_bic %> <% end %>
  34. module ApplicationPolicy extend KittyPolicy::DSL can :access, Account do |user, account|

    user.admin? || account.member?(user) end end # generates ApplicationPolicy.can_access_account?(user, account) # can be accessed as ApplicationPolicy.can?(user, :access, account) ApplicationPolicy.authorize!(user, :access, account) # raise KittyPolicy::AccessDenied
  35. module ApplicationPolicy extend KittyPolicy::DSL extend ApplicationPolicy::RolesDSL # Role stacking. #

    Upper role can do everything lower role can do
 # # - director > supervisor > operator > cashier # - chief technician > technician # - chief accountant > accountant building_cashier_can :view, Withdraw building_operator_can :create, Withdraw building_supervisor_can :update, Withdraw building_director_can :destroy, Withdraw # ... end
  36. class CalendarEventsController < ApplicationController require_account_feature :calendar def show @event =

    find_record Calendar::Event end def edit @event = find_record Calendar::Event, params[:id], authorize: :manage end
 # ... end
  37. class CalendarEventsController < ApplicationController require_account_feature :calendar def show @event =

    find_record Calendar::Event end def edit @event = find_record Calendar::Event, params[:id], authorize: :manage end
 # ... end
  38. class CalendarEventsController < ApplicationController require_account_feature :calendar def show @event =

    find_record Calendar::Event end def edit @event = find_record Calendar::Event, params[:id], authorize: :manage end
 # ... end
  39. render template: 'system/feature_unavailable' end rescue_from KittyPolicy::AccessDenied do |error| ErrorReporting.capture_exception(error) render

    template: 'system/unauthorized' end end class_methods do def require_account_feature(feature_name) self.required_account_feature = feature_name end end private def find_record(scope, id = :none, authorize: :view) record = scope.find(id == :none ? params[:id] : id) ApplicationPolicy.authorize!(current_user, authorize, record) if self.class.required_account_feature.present? Accounts.feature_available!(record, self.class.required_account_feature) end
  40. private def find_record(scope, id = :none, authorize: :view) record =

    scope.find(id == :none ? params[:id] : id) ApplicationPolicy.authorize!(current_user, authorize, record) if self.class.required_account_feature.present? Accounts.feature_available!(record, self.class.required_account_feature) end record end end
  41. module Accounts extend self def account_of(record) if record.is_a?(Account) record elsif

    record.respond_to?(:account) record.account elsif record.respond_to?(:building) record.building.account else raise "Can't find account for #{record.class}##{record.id}" end end def feature_available?(record, feature_name) account_of(record).features.available?(feature_name) end def feature_available!(record, feature_name) return if feature_available?(record, feature_name) raise FeatureUnavailable.new(record, feature_name) end end
  42. I don't have many "service objects". In most cases, the

    "service object", I inline it in a "form object".
  43. class Calendar::AttendeeForm < ApplicationForm main_model( :attendee, attributes: %i(user_id), read: %i(event

    building), save: true ) validate :ensure_user_is_allowed def initialize(event) @attendee = event.attendees.new end def user_options @user_options ||= building.account.member_users end def perform Notifications.notify_about(@attendee) end private def ensure_user_is_allowed
  44. class ApplicationForm include MiniForm::Model def submit(params) update(params.require(:form).permit(self.class.attribute_names)) end def submit!(params)

    update!(params.require(:form).permit(self.class.attribute_names)) end end module ApplicationHelper def app_form(record, options = {}) options[:as] ||= :form 
 # ... end # ...
  45. class CalendarEventAttendeesController < ApplicationController require_account_feature :calendar def new @attendee =

    Calendar::AttendeeForm.new(find_event) end def create @attendee = Calendar::AttendeeForm.new(find_event) @attendee.submit(params) respond_with @attendee, location: event_path(@attendee.event) end private def find_event find_record Calendar::Event, params[:event_id] end end
  46. class CalendarEventAttendeesController < ApplicationController require_account_feature :calendar def new @attendee =

    Calendar::AttendeeForm.new(find_event) end def create @attendee = Calendar::AttendeeForm.new(find_event) @attendee.submit(params) respond_with @attendee, location: event_path(@attendee.event) end private def find_event find_record Calendar::Event, params[:event_id] end end gem 'responders'
  47. I keep business logic in the form object, except if

    it is going to be used somewhere else. I follow the same logic for ActiveJob objects.
  48. class Finance::TransactionsSearch def initialize(filters: {}) @filters = filters end def

    results scope = Transaction.all scope = scope.where(user_id: @filters[:user_id]) if @filters[:user_id] scope = scope.where("amount > ?", @filters[:min_amount]) if @filters[:min_amount] scope = scope.where("amount < ?", @filters[:max_amount]) if @filters[:max_amount] scope = scope.where("created_at > ?", @filters[:start_date]) if @filters[:start_d scope = scope.where("created_at < ?", @filters[:end_date]) if @filters[:end_date] scope end end
  49. class Finance::TransactionsSearch def initialize(filters: {}, page: 0) @filters = filters

    @page = page end def results @results ||= fetch_results end def fetch_results scope = Transaction.all scope = scope.where(user_id: @filters[:user_id]) if @filters[:user_id] scope = scope.where("amount > ?", @filters[:min_amount]) if @filters[:min_amount] scope = scope.where("amount < ?", @filters[:max_amount]) if @filters[:max_amount] scope = scope.where("created_at > ?", @filters[:start_date]) if @filters[:start_d scope = scope.where("created_at < ?", @filters[:end_date]) if @filters[:end_date] scope.page(@page) end end
  50. class Finance::TransactionsSearch include SearchObject.module(:kaminary) scope { Transaction.all } option(:user_id) option(:min_amount)

    { |scope, v| scope.where("amount > ?", v) } option(:max_amount) { |scope, v| scope.where("amount < ?", v) } option(:start_date) { |scope, v| scope.where("created_at > ?", v) } option(:end_date) { |scope, v| scope.where("created_at < ?", v) } end
  51. class Finance::TransactionsSearch include SearchObject.module(:kaminary) scope { Transaction.all } option :user_id

    option :min_amount, with: :filter_by_min_amount option :max_amount, with: :filter_by_max_amount option :start_date, with: :filter_by_start_date option :end_date, with: :filter_by_end_date private def filter_by_min_amount(scope, value) scope.where("amount > ?", value) end def filter_by_max_amount(scope, value) scope.where("amount < ?", value) end def filter_by_start_date(scope, value) scope.where("created_at > ?", value) end
  52. put everything in one folder put everything in one folder

    put every object type in its own folder
  53. You only get special folder if you called in special

    way. Those are the special folders app/components app/controllers app/jobs app/models app/graph app/mailers
  54. require 'features_helper' feature 'Feature: Calendar' do scenario 'attendee - create

    > list > delete' do building = create :building sign_in_operator building account_member = create :account_member, account: building.account event = create :calendar_event, building: building visit event_path(event) click_on I18n.t(:action_new_attendee) # test: validation submit_form expect_form_errors :blank # test: create submit_form!( user_id: account_member.user_id, )
  55. submit_form expect_form_errors :blank # test: create submit_form!( user_id: account_member.user_id, )

    expect(page).to have_content account_member.name attendee = event.attendees.last! expect(attendee.user).to eq account_member.user # test: destroy click_on_destroy expect_flash_message :destroy expect_to_be_destroyed attendee end end